Compare commits

..

10 Commits

Author SHA1 Message Date
4708a70076 ci: add og-tasks (board) auto deploy to CD pipeline
Some checks failed
CI / test (push) Has been cancelled
CI / deploy (push) Has been cancelled
2026-04-13 10:43:53 +00:00
小糯 🐱
d696707430 feat(board): responsive mobile layout — tab-based kanban on small screens
- Mobile (<lg): status tabs replace horizontal columns, single column view
- Tab bar with colored underlines, sticky at top, horizontally scrollable
- Mobile column shows full-width task cards with 'Add Task' button
- Header: compact on mobile — shorter title, 'New' instead of 'New Task'
- Desktop (>=lg): unchanged horizontal kanban board
- Error banner margin adapts to screen size
2026-04-13 18:04:34 +08:00
小糯 🐱
32d85223f2 feat(ui): responsive layout — mobile sidebar drawer + scrollable tables
- Sidebar becomes off-canvas drawer on mobile (<lg), slides in/out
- Mobile top bar with hamburger menu and current page title
- Backdrop overlay when drawer is open, ESC to close
- All tables wrapped in overflow-x-auto with min-width for scroll
- Search/filter bars stack vertically on small screens
- Content padding scales: p-4 (mobile) → p-6 (tablet) → p-8 (desktop)
- Body scroll locked when mobile drawer is open
2026-04-13 18:00:10 +08:00
小糯 🐱
f950654827 feat(ui): visual refresh — custom theme, refined layout, fixed bugs
- Custom color system (surface-0..4, accent, mint) replacing raw gray-xxx
- Inter + JetBrains Mono fonts via Google Fonts
- Refined sidebar: compact logo, geometric icons, subtle active states
- Fixed bg-gray-850 bug (invalid Tailwind class) in 9 components
- Polished login page with centered card + gradient logo
- Unified card/table/button/input styling across all components
- Subtle grain texture overlay for depth
- Smoother animations (fade-in, slide-up)
2026-04-13 17:56:39 +08:00
bc12a4bb18 feat(engine): API Key read access for events, objects, projections (#36)
- Auth middleware now accepts API Key for GET on data endpoints
  (/events, /objects, /projections, /event-defs, /projection-defs, /object-defs)
- Admin write ops (POST defs, reactions, api-keys) still require API_TOKEN
- Valid API Key + admin op → 403 Forbidden
- Invalid token → 401 Unauthorized
- Add 5 tests for API Key read access (#37)

closes #36
2026-04-13 09:51:09 +00:00
8e0f1e3a28 docs: add CONTRIBUTING.md — dev workflow, CI/CD, architecture, conventions
小橘 🍊(NEKO Team)
2026-04-13 09:28:59 +00:00
5d2ece9f80 ci: fix CD — install UI dependencies before build
小橘 🍊
2026-04-13 09:20:27 +00:00
066f120134 ci: add CD — auto deploy to Cloudflare Workers on push to main
Test → Build UI → wrangler deploy. Secrets configured.

小橘 🍊(NEKO Team)
2026-04-13 09:18:13 +00:00
小糯 🐱
5cc46b3e9b fix(engine): return 204 for /favicon.ico instead of 401
Browser requests to /favicon.ico were hitting the auth middleware.
Added explicit route + auth bypass.
2026-04-13 17:14:51 +08:00
bd4b79bd7b fix(ui): Projection ref params now filter objects by matching type
- RefCombobox infers object_type from param name when not explicitly set
- e.g. param 'agent' automatically shows only agent-type objects
- Also fixed engine type signature to preserve object_type in params

小橘 🍊(NEKO Team)
2026-04-13 09:13:38 +00:00
27 changed files with 1000 additions and 322 deletions

View File

@ -16,3 +16,28 @@ jobs:
node-version: 22
- run: npm install
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install
- name: Build UI
run: cd packages/engine/ui && npm install && npm run build && cp dist/index.html ../src/ui.html
- name: Deploy Engine to Cloudflare Workers
run: cd packages/engine && npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Build Board
run: cd packages/board && npm install && npm run build
- name: Deploy Board to Cloudflare Workers
run: cd packages/board && npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

215
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,215 @@
# Contributing to OGraph
## Project Structure
```
packages/
engine/ # CF Worker — API + Engine logic + UI
src/
index.ts # Hono routes + middleware
engine.ts # Core engine functions (CRUD, projections, reactions)
types.ts # TypeScript interfaces
ui.html # Built UI (committed, auto-generated)
ui/ # React SPA (Vite + Tailwind + headless-ui)
src/
App.tsx
api.ts
components/
wrangler.toml
cli/ # CLI client (@uncaged/ograph-cli)
src/
client.ts # HTTP client for OGraph API
commands/ # CLI commands (events, projections, reactions, etc.)
dispatcher/ # Local daemon — polls projections, notifies Agent
board/ # (experimental) Kanban board UI
```
## Prerequisites
- Node.js 22+
- npm (workspaces)
- Cloudflare account (for deployment)
- `wrangler` CLI (installed via npm)
## Setup
```bash
git clone https://github.com/oc-xiaoju/ograph.git
cd ograph
npm install
```
UI has its own dependencies:
```bash
cd packages/engine/ui
npm install
```
## Development Workflow
### Engine (API + Core Logic)
```bash
cd packages/engine
npm run dev # Local dev server (wrangler dev)
npm test # Run tests (vitest, 105+ tests)
```
### UI (React SPA)
```bash
cd packages/engine/ui
npm run dev # Vite dev server (hot reload)
npm run build # Build → dist/index.html (single-file)
```
After building UI, copy to engine:
```bash
cp packages/engine/ui/dist/index.html packages/engine/src/ui.html
```
### CLI
```bash
cd packages/cli
npm run build # TypeScript compile
npm test # Run tests (vitest, 31+ tests)
npm run dev # Watch mode
```
### Run All Tests
```bash
npm test # Runs tests across all workspaces
```
## CI/CD
**Pipeline:** `.github/workflows/ci.yml`
On every push to `main`:
1. **Test**`npm install``npm test` (all workspaces)
2. **Deploy** — Build UI → copy `ui.html``wrangler deploy` to Cloudflare Workers
On pull requests:
1. **Test** only (no deploy)
### Secrets (GitHub Actions)
| Secret | Description |
|--------|-------------|
| `CLOUDFLARE_API_TOKEN` | Wrangler deploy auth |
| `CLOUDFLARE_ACCOUNT_ID` | CF account |
### Production URL
- API: `https://ograph.shazhou.workers.dev`
- UI: `https://ograph.shazhou.workers.dev/ui`
## Commit Conventions
Format: `type(scope): description`
Types: `feat`, `fix`, `refactor`, `ci`, `docs`, `test`, `chore`
Scopes: `engine`, `cli`, `ui`, `dispatcher`, `board`
Examples:
```
feat(engine): add incremental event query with ?after=N
fix(ui): Projection ref params now filter objects by matching type
ci: add CD — auto deploy to Cloudflare Workers on push to main
```
Sign commits:
```
小橘 🍊(NEKO Team)
小墨 🖊️(KUMA Team)
```
Git config:
```bash
git config user.name "小橘"
git config user.email "xiaoju@shazhou.work"
```
## Architecture Notes
### Engine (Cloudflare Worker)
- **Runtime:** Cloudflare Workers (D1 database, Hono framework)
- **Auth:** API Key in `Authorization: Bearer ogk_xxx` header
- **UI:** Single HTML file served at `/ui`, built with Vite singlefile plugin
- **Logs:** Request logs auto-cleaned (7-day retention via `waitUntil`)
### Key Design Decisions
- **Snake_case everywhere** — API, DB, event names (`task_created`, not `taskCreated`)
- **Event names:** `{object}_{past_participle}` (e.g. `task_created`, `comment_added`)
- **Params use `_id` suffix**`task_id`, `agent_id`
- **Content-addressed hashing** — defs are immutable versions identified by hash
- **Projection health:** expression errors → `errored` status, returns stale value, no reaction triggered
- **Lazy computation:** projections compute on query, not on event emit
### UI Routing
Hash-based routing (`#/events`, `#/reaction-logs`). No react-router dependency — hand-rolled.
Pages:
| Route | Page |
|-------|------|
| `#/health` | Health dashboard |
| `#/object-defs` | Object type definitions |
| `#/objects` | Object instances |
| `#/event-defs` | Event type definitions |
| `#/events` | Event log |
| `#/projection-defs` | Projection definitions |
| `#/projections` | Query projections |
| `#/reactions` | Reaction definitions |
| `#/reaction-logs` | Reaction execution logs |
| `#/request-logs` | API request logs |
| `#/api-keys` | API key management |
### D1 Storage
- Free tier: 500 MB storage, 5M reads/writes per month
- TEXT fields: no length limit (SQLite underneath, ~1 MB per row)
- Logs: auto-pruned after 7 days
- Events: append-only, never deleted
### Testing
Engine tests use miniflare (local D1). Tests cover:
- CRUD for all entity types (object defs, event defs, projection defs, etc.)
- Projection computation + incremental reduce
- Reaction execution + handler sandboxing
- API key auth + permissions
- Error handling + edge cases
To add a test, edit `packages/engine/src/index.test.ts`.
## Manual Deploy (if needed)
```bash
cd packages/engine
# Build UI first
cd ui && npm run build && cp dist/index.html ../src/ui.html && cd ..
# Deploy
npx wrangler deploy
```
Requires `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` env vars.
## D1 Database Access
```bash
# Query production D1
npx wrangler d1 execute ograph --remote --command "SELECT COUNT(*) FROM events"
# Time Travel (point-in-time recovery)
npx wrangler d1 time-travel ograph --before <timestamp>
```
---
*Built by 小橘 🍊 & 小墨 🖊️ — NEKO + KUMA Teams*

View File

@ -151,7 +151,7 @@ function App() {
/>
{error && (
<div className="mx-6 mt-3 rounded-md bg-red-500/10 border border-red-500/30 px-4 py-2 text-sm text-red-400">
<div className="mx-3 sm:mx-6 mt-3 rounded-md bg-red-500/10 border border-red-500/30 px-4 py-2 text-sm text-red-400">
{error}
</div>
)}
@ -171,7 +171,7 @@ function App() {
)}
{connected && (
<main className="flex-1 overflow-hidden">
<main className="flex-1 overflow-hidden flex flex-col">
<KanbanBoard
tasks={tasks}
onTaskClick={handleTaskClick}

View File

@ -10,36 +10,37 @@ interface HeaderProps {
export function Header({ onNewTask, onOpenSettings, taskCount, connected }: HeaderProps) {
return (
<header className="border-b border-zinc-800 bg-zinc-950 px-6 py-3">
<header className="border-b border-zinc-800 bg-zinc-950 px-4 sm:px-6 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-zinc-800 text-zinc-300">
<LayoutDashboard className="h-4 w-4" />
<div className="flex items-center gap-2.5 sm:gap-3 min-w-0">
<div className="flex items-center justify-center h-7 w-7 sm:h-8 sm:w-8 rounded-lg bg-zinc-800 text-zinc-300 shrink-0">
<LayoutDashboard className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</div>
<div>
<h1 className="text-lg font-semibold text-zinc-100 leading-tight">OGraph Task Board</h1>
<p className="text-xs text-zinc-500">
<div className="min-w-0">
<h1 className="text-sm sm:text-lg font-semibold text-zinc-100 leading-tight truncate">OGraph Tasks</h1>
<p className="text-[11px] sm:text-xs text-zinc-500 truncate">
{connected
? `${taskCount} task${taskCount !== 1 ? "s" : ""}`
: "Not connected — configure in Settings"}
: "Not connected"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<Button
onClick={onOpenSettings}
variant="ghost"
size="icon"
className="text-zinc-500 hover:text-zinc-200"
className="text-zinc-500 hover:text-zinc-200 h-8 w-8"
title="Settings"
>
<Settings className="h-4 w-4" />
</Button>
<Button onClick={onNewTask} size="sm" className="gap-1.5" disabled={!connected}>
<Plus className="h-4 w-4" />
New Task
<Button onClick={onNewTask} size="sm" className="gap-1 sm:gap-1.5 text-xs sm:text-sm" disabled={!connected}>
<Plus className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span className="hidden sm:inline">New Task</span>
<span className="sm:hidden">New</span>
</Button>
</div>
</div>

View File

@ -1,4 +1,5 @@
import { ALL_STATUSES, type Task, type TaskStatus } from "@/types"
import { useState } from "react"
import { ALL_STATUSES, STATUS_CONFIG, type Task, type TaskStatus } from "@/types"
import { KanbanColumn } from "@/components/KanbanColumn"
interface KanbanBoardProps {
@ -8,30 +9,85 @@ interface KanbanBoardProps {
onMoveTask: (taskId: number, status: TaskStatus) => void
}
const tabColors: Record<string, string> = {
zinc: "border-zinc-500 text-zinc-300",
blue: "border-blue-500 text-blue-400",
amber: "border-amber-500 text-amber-400",
purple: "border-purple-500 text-purple-400",
emerald: "border-emerald-500 text-emerald-400",
}
export function KanbanBoard({ tasks, onTaskClick, onAddTask, onMoveTask }: KanbanBoardProps) {
return (
<div className="flex gap-4 overflow-x-auto pb-4 px-6 pt-4">
{ALL_STATUSES.map((status) => {
const columnTasks = tasks
const [mobileTab, setMobileTab] = useState<TaskStatus>("todo")
const getColumnTasks = (status: TaskStatus) =>
tasks
.filter((t) => t.status === status)
.sort((a, b) => {
// Sort by priority first (p0 first), then by updatedAt descending
const prioOrder = a.priority.localeCompare(b.priority)
if (prioOrder !== 0) return prioOrder
return b.updatedAt - a.updatedAt
})
return (
<>
{/* Mobile: tab switcher */}
<div className="lg:hidden border-b border-zinc-800 bg-zinc-950/80 backdrop-blur-sm sticky top-0 z-10">
<div className="flex overflow-x-auto px-2 gap-0.5 scrollbar-hide">
{ALL_STATUSES.map((status) => {
const config = STATUS_CONFIG[status]
const count = tasks.filter((t) => t.status === status).length
const isActive = mobileTab === status
return (
<button
key={status}
onClick={() => setMobileTab(status)}
className={`
shrink-0 px-3 py-2.5 text-xs font-medium border-b-2 transition-all
${isActive
? tabColors[config.color]
: "border-transparent text-zinc-500 hover:text-zinc-300"
}
`}
>
{config.label}
{count > 0 && (
<span className={`ml-1.5 text-[10px] ${isActive ? "opacity-80" : "opacity-50"}`}>
{count}
</span>
)}
</button>
)
})}
</div>
</div>
{/* Mobile: single column view */}
<div className="lg:hidden px-3 py-3 flex-1 overflow-y-auto">
<KanbanColumn
key={mobileTab}
status={mobileTab}
tasks={getColumnTasks(mobileTab)}
onTaskClick={onTaskClick}
onAddTask={onAddTask}
onDrop={onMoveTask}
mobile
/>
</div>
{/* Desktop: horizontal kanban */}
<div className="hidden lg:flex gap-4 overflow-x-auto pb-4 px-6 pt-4">
{ALL_STATUSES.map((status) => (
<KanbanColumn
key={status}
status={status}
tasks={columnTasks}
tasks={getColumnTasks(status)}
onTaskClick={onTaskClick}
onAddTask={onAddTask}
onDrop={onMoveTask}
/>
)
})}
))}
</div>
</>
)
}

View File

@ -9,6 +9,7 @@ interface KanbanColumnProps {
onTaskClick: (task: Task) => void
onAddTask: (status: TaskStatus) => void
onDrop: (taskId: number, status: TaskStatus) => void
mobile?: boolean
}
const borderColors: Record<string, string> = {
@ -27,7 +28,7 @@ const countBgColors: Record<string, string> = {
emerald: "bg-emerald-500/15 text-emerald-400",
}
export function KanbanColumn({ status, tasks, onTaskClick, onAddTask, onDrop }: KanbanColumnProps) {
export function KanbanColumn({ status, tasks, onTaskClick, onAddTask, onDrop, mobile }: KanbanColumnProps) {
const config = STATUS_CONFIG[status]
function handleDragOver(e: React.DragEvent) {
@ -43,6 +44,48 @@ export function KanbanColumn({ status, tasks, onTaskClick, onAddTask, onDrop }:
}
}
if (mobile) {
// Mobile: full-width, no column header (tabs handle that), no fixed width
return (
<div
className="flex flex-col"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", String(task.id))
e.dataTransfer.effectAllowed = "move"
}}
>
<TaskCard task={task} onClick={onTaskClick} />
</div>
))}
{tasks.length === 0 && (
<div className="flex items-center justify-center h-24 text-xs text-zinc-600 border border-dashed border-zinc-800 rounded-lg">
No tasks
</div>
)}
</div>
<Button
variant="ghost"
className="mt-3 w-full text-zinc-500 hover:text-zinc-300 border border-dashed border-zinc-800 hover:border-zinc-600 h-9 text-xs"
onClick={() => onAddTask(status)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Task
</Button>
</div>
)
}
// Desktop: fixed-width column
return (
<div
className="flex flex-col min-w-[272px] w-[272px] shrink-0"

View File

@ -478,7 +478,7 @@ export async function createProjectionDef(
db: D1Database,
name: string,
sources: Array<{ event_def: string; bindings: Record<string, string>; expression: string }>,
params: Record<string, { type: 'ref' }>,
params: Record<string, { type: 'ref'; object_type?: string }>,
valueSchema: { type: string },
initialValue: any,
): Promise<{ name: string; hash: string }> {

View File

@ -2250,6 +2250,70 @@ describe('API Key Management', () => {
})
})
// ============================================
// API Key Read Access (#36)
// ============================================
describe('API Key Read Access', () => {
let apiKey: string
let agentId: number
beforeEach(async () => {
// Need object-def 'agent' first
await app.fetch(req('POST', '/object-defs', { name: 'agent' }), { DB: db, API_TOKEN })
const agentRes = await app.fetch(req('POST', '/objects', { type: 'agent' }), { DB: db, API_TOKEN })
agentId = (await agentRes.json()).id
const keyRes = await app.fetch(
req('POST', '/api-keys', { name: 'read-test', allowed_events: ['task_created'] }),
{ DB: db, API_TOKEN },
)
apiKey = (await keyRes.json()).key
})
it('GET /events with API Key → 200', async () => {
const res = await app.fetch(
req('GET', '/events?ref=' + agentId, undefined, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(200)
const json = await res.json()
expect(json.events).toBeDefined()
})
it('GET /objects/:id with API Key → 200', async () => {
const res = await app.fetch(
req('GET', `/objects/${agentId}`, undefined, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(200)
})
it('GET /event-defs with API Key → 200', async () => {
const res = await app.fetch(
req('GET', '/event-defs', undefined, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(200)
})
it('POST /event-defs with API Key → 403', async () => {
const res = await app.fetch(
req('POST', '/event-defs', { name: 'blocked_type', schema: { properties: { x: { type: 'string' } } } }, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(403)
})
it('GET /api-keys with API Key → 403', async () => {
const res = await app.fetch(
req('GET', '/api-keys', undefined, apiKey),
{ DB: db, API_TOKEN },
)
expect(res.status).toBe(403)
})
})
// ============================================
// Request Logs (#221)
// ============================================

View File

@ -117,6 +117,10 @@ app.use('*', async (c, next) => {
// UI (no auth, served before auth middleware)
// ============================================
app.get('/favicon.ico', (c) => {
return new Response(null, { status: 204 })
})
app.get('/ui', (c) => {
return c.html(UI_HTML)
})
@ -141,11 +145,47 @@ app.get('/schema', async (c) => {
return c.json(schema)
})
// Auth middleware for all routes except health, schema, ui, and POST /events (which has its own dual auth)
// Auth middleware: API_TOKEN for admin ops, API Key allowed for reads + POST /events
// Read-only paths that API Keys can access
const API_KEY_READABLE_PREFIXES = ['/events', '/objects', '/projections', '/event-defs', '/projection-defs', '/object-defs']
app.use('*', async (c, next) => {
if (c.req.path === '/health' || c.req.path === '/schema' || c.req.path.startsWith('/ui')) return next()
if (c.req.path === '/health' || c.req.path === '/schema' || c.req.path === '/favicon.ico' || c.req.path.startsWith('/ui')) return next()
if (c.req.method === 'POST' && c.req.path === '/events') return next()
return bearerAuth(c.env.API_TOKEN)(c, next)
const authHeader = c.req.header('Authorization')
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!bearerToken) {
return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Missing or invalid Authorization header')
}
// Admin token: full access
if (bearerToken === c.env.API_TOKEN) return next()
// API Key: read-only access to data endpoints
if (c.req.method === 'GET') {
const isReadable = API_KEY_READABLE_PREFIXES.some(p => c.req.path.startsWith(p))
if (isReadable) {
const result = await validateApiKey(c.env.DB, bearerToken)
if (result.valid) {
if (result.apiKey) {
c.set('apiKeyId', result.apiKey.id)
c.set('apiKeyName', result.apiKey.name)
}
return next()
}
}
}
// Check if it's a valid API Key trying admin ops → 403
const keyCheck = await validateApiKey(c.env.DB, bearerToken)
if (keyCheck.valid) {
return apiError(c, 403, ErrorCode.FORBIDDEN, 'API key cannot perform admin operations')
}
// Invalid token entirely → 401
return apiError(c, 401, ErrorCode.UNAUTHORIZED, 'Invalid or unauthorized token')
})
// ============================================

File diff suppressed because one or more lines are too long

View File

@ -48,7 +48,6 @@ function App() {
const [tokenInput, setTokenInput] = useState('')
const [checking, setChecking] = useState(true)
// Sync hash → state on popstate (browser back/forward)
useEffect(() => {
const onHashChange = () => setPageState(getPageFromHash())
window.addEventListener('hashchange', onHashChange)
@ -59,7 +58,6 @@ function App() {
}
}, [])
// Navigate: update hash + state
const setPage = (p: Page) => {
setHash(p)
setPageState(p)
@ -83,7 +81,7 @@ function App() {
if (checking) {
return (
<div className="flex items-center justify-center h-screen bg-gray-950">
<div className="flex items-center justify-center h-screen bg-surface-0">
<Spinner />
</div>
)
@ -91,37 +89,43 @@ function App() {
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 className="flex items-center justify-center h-screen bg-surface-0 text-gray-100 px-4 grain">
<div className="animate-slide-up">
<div className="bg-surface-1 border border-white/[0.06] p-8 rounded-2xl shadow-2xl max-w-sm w-full">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent to-blue-400 flex items-center justify-center text-white text-lg font-bold mx-auto mb-4 shadow-lg shadow-accent/25">
G
</div>
<h2 className="text-xl font-bold text-white tracking-tight">OGraph</h2>
<p className="text-gray-500 text-sm mt-1">Enter your API token</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"
type="password"
className="w-full px-4 py-2.5 bg-surface-3 border border-white/[0.08] rounded-lg text-sm placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent/40 transition-all"
placeholder="Bearer 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"
disabled={!tokenInput.trim()}
className="w-full mt-3 px-4 py-2.5 bg-accent hover:bg-accent-dim rounded-lg text-sm font-semibold text-white shadow-lg shadow-accent/20 hover:shadow-accent/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
Continue
</button>
</div>
</div>
</div>
)
}
return (
<Layout page={page} onPageChange={setPage}>
<div className="animate-fadeIn">
{page === 'health' && <Health />}
{page === 'object-defs' && <ObjectDefs />}
{page === 'objects' && <Objects />}
@ -133,7 +137,6 @@ function App() {
{page === 'api-keys' && <ApiKeys />}
{page === 'reaction-logs' && <ReactionLogs />}
{page === 'request-logs' && <RequestLogs />}
</div>
</Layout>
)
}

View File

@ -81,7 +81,7 @@ export default function ApiKeys() {
return (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold mb-6">API Keys</h2>
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">API Keys</h2>
{/* Key reveal modal */}
{shownKey && (
@ -91,7 +91,7 @@ export default function ApiKeys() {
<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="bg-surface-3 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}
@ -111,7 +111,7 @@ export default function ApiKeys() {
)}
{/* Create form */}
<div className="bg-gray-900/50 backdrop-blur rounded-lg border border-gray-800 p-4 mb-6">
<div className="bg-surface-1 rounded-lg border border-white/[0.06] 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>
@ -120,7 +120,7 @@ export default function ApiKeys() {
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"
className="px-3 py-2 bg-surface-3 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
@ -130,7 +130,7 @@ export default function ApiKeys() {
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"
className="px-3 py-2 bg-surface-3 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-64"
/>
</div>
<div>
@ -140,7 +140,7 @@ export default function ApiKeys() {
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"
className="px-3 py-2 bg-surface-3 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-24"
/>
</div>
<button
@ -153,12 +153,13 @@ export default function ApiKeys() {
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
<div className="bg-surface-1 rounded-xl overflow-hidden border border-white/[0.06]">
{data.length === 0 ? (
<EmptyState message="No API keys found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead className="bg-surface-3/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">
@ -184,8 +185,8 @@ export default function ApiKeys() {
<tr
key={key.id}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-white/[0.02]'
} hover:bg-surface-3/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>
@ -222,6 +223,7 @@ export default function ApiKeys() {
))}
</tbody>
</table>
</div>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>

View File

@ -1,24 +1,22 @@
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 className="flex items-center justify-center p-16">
<div className="relative">
<div className="w-8 h-8 rounded-full border-2 border-surface-3" />
<div className="absolute inset-0 w-8 h-8 rounded-full border-2 border-transparent border-t-accent animate-spin" />
</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="flex items-center justify-center py-16 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 className="mx-auto w-10 h-10 rounded-xl bg-surface-3 flex items-center justify-center mb-3">
<span className="text-lg text-gray-600"></span>
</div>
<p className="text-sm">{message}</p>
</div>
</div>
)
@ -27,10 +25,79 @@ export function EmptyState({ message = 'No data found' }: { message?: string })
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>
<span
className="inline-block px-1.5 py-0.5 bg-surface-3/60 rounded font-mono text-[11px] text-gray-500 cursor-default select-all"
title={hash}
>
{display}
</span>
)
}
export function SectionHeader({ title, count, action }: { title: string; count?: number; action?: React.ReactNode }) {
return (
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-white tracking-tight">{title}</h2>
{count !== undefined && (
<span className="px-2 py-0.5 rounded-full bg-surface-3 text-[11px] font-medium text-gray-400">
{count}
</span>
)}
</div>
{action}
</div>
)
}
export function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<div className={`bg-surface-1 border border-white/[0.06] rounded-xl overflow-hidden ${className}`}>
{children}
</div>
)
}
export function ErrorBanner({ message }: { message: string }) {
return (
<div className="rounded-lg bg-red-500/[0.08] border border-red-500/20 px-4 py-3 text-sm text-red-400 mb-4">
{message}
</div>
)
}
export function JsonBlock({ data }: { data: unknown }) {
const text = JSON.stringify(data, null, 2)
return (
<pre className="p-3 bg-surface-0 rounded-lg text-xs font-mono overflow-x-auto border border-white/[0.04] text-gray-400 leading-relaxed">
{text.split('\n').map((line, i) => {
const match = line.match(/^(\s*)"([^"]+)":\s*(.*)/)
if (match) {
return (
<div key={i}>
<span>{match[1]}</span>
<span className="text-blue-400">"{match[2]}"</span>
<span className="text-gray-600">: </span>
<span className="text-emerald-400">{match[3]}</span>
</div>
)
}
return <div key={i} className="text-gray-500">{line}</div>
})}
</pre>
)
}
export function StatusDot({ status }: { status: 'ok' | 'error' | 'warning' | 'idle' }) {
const colors = {
ok: 'bg-emerald-500',
error: 'bg-red-500',
warning: 'bg-amber-500',
idle: 'bg-gray-600',
}
return <div className={`w-2 h-2 rounded-full ${colors[status]}`} />
}
export function Pagination({
total,
limit,
@ -53,44 +120,41 @@ export function Pagination({
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 justify-between border-t border-white/[0.06] px-4 py-3 mt-0">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-400">
{startItem}-{endItem} of {total}
<span className="text-xs text-gray-500">
{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
onPageChange(0)
}}
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"
className="bg-surface-3 text-gray-300 border border-white/[0.06] rounded-md px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-accent/50"
>
<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">
<div className="flex items-center gap-1.5">
<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"
className="px-2.5 py-1 bg-surface-3 text-gray-400 rounded-md border border-white/[0.06] disabled:opacity-30 disabled:cursor-not-allowed hover:bg-surface-4 hover:text-gray-200 text-xs font-medium"
>
Previous
Prev
</button>
<span className="text-sm text-gray-400">
Page {currentPage} / {totalPages || 1}
<span className="text-xs text-gray-500 px-2">
{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"
className="px-2.5 py-1 bg-surface-3 text-gray-400 rounded-md border border-white/[0.06] disabled:opacity-30 disabled:cursor-not-allowed hover:bg-surface-4 hover:text-gray-200 text-xs font-medium"
>
Next
Next
</button>
</div>
</div>

View File

@ -77,7 +77,7 @@ export default function EmitEventModal({ eventDef, onClose, onSuccess }: EmitEve
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"
className="w-4 h-4 rounded border-gray-600 bg-surface-3 text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
/>
<span className="text-sm text-gray-200 font-mono">{name}</span>
</label>
@ -94,7 +94,7 @@ export default function EmitEventModal({ eventDef, onClose, onSuccess }: EmitEve
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"
className="w-full px-3 py-2 bg-surface-3 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>
@ -108,7 +108,7 @@ export default function EmitEventModal({ eventDef, onClose, onSuccess }: EmitEve
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-800">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.06]">
<h3 className="text-lg font-semibold text-gray-100">
Emit <span className="text-blue-400 font-mono">{eventDef.name}</span>
</h3>

View File

@ -39,13 +39,14 @@ export default function EventDefs() {
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">
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">Event Definitions</h2>
<div className="bg-surface-1 rounded-xl overflow-hidden border border-white/[0.06]">
{data.length === 0 ? (
<EmptyState message="No event definitions found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead className="bg-surface-3/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
@ -69,8 +70,8 @@ export default function EventDefs() {
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-white/[0.02]'
} hover:bg-surface-3/60`}
>
<td className="px-4 py-3 font-mono text-gray-100">{def.name}</td>
<td className="px-4 py-3">
@ -87,7 +88,7 @@ export default function EventDefs() {
{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">
<pre className="mt-2 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-white/[0.06]">
{JSON.stringify(def.schema, null, 2)
.split('\n')
.map((line, i) => {
@ -121,6 +122,7 @@ export default function EventDefs() {
))}
</tbody>
</table>
</div>
)}
</div>

View File

@ -50,30 +50,31 @@ export default function Events() {
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">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-5">
<h2 className="text-lg font-semibold text-white tracking-tight">Events</h2>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<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"
className="px-4 py-2 bg-surface-3 border border-white/[0.08] rounded-lg focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/30 transition-all"
placeholder="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"
className="px-4 py-2 bg-accent hover:bg-accent-dim rounded-lg text-sm font-medium transition-all"
>
Search
</button>
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
<div className="bg-surface-1 rounded-xl overflow-hidden border border-white/[0.06]">
{data.length === 0 ? (
<EmptyState message="No events yet" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead className="bg-surface-3/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">
@ -92,8 +93,8 @@ export default function Events() {
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-white/[0.02]'
} hover:bg-surface-3/60`}
>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{event.id}</td>
<td className="px-4 py-3">
@ -108,7 +109,7 @@ export default function Events() {
{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">
<pre className="mt-2 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-white/[0.06]">
{JSON.stringify(event.payload, null, 2)
.split('\n')
.map((line, i) => {
@ -134,6 +135,7 @@ export default function Events() {
))}
</tbody>
</table>
</div>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>

View File

@ -19,8 +19,8 @@ export default function Health() {
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">
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">Health Check</h2>
<div className="bg-surface-1 border border-white/[0.06] 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>
@ -30,7 +30,7 @@ export default function Health() {
</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>
<span className="text-white font-mono bg-surface-3/50 px-3 py-1 rounded">{data?.version}</span>
</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, useState, useEffect } from 'react'
type Page =
| 'health'
@ -23,86 +23,181 @@ const navGroups: Array<{ label: string; items: Array<{ id: Page; label: 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: '📊' },
{ 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: 'objects', label: 'Objects', icon: '' },
{ id: 'events', label: 'Events', icon: '⚡' },
{ id: 'projections', label: 'Projections', icon: '📈' },
{ id: 'reactions', label: 'Reactions', icon: '🔔' },
{ id: 'projections', label: 'Projections', icon: '' },
{ id: 'reactions', label: 'Reactions', icon: '' },
],
},
{
label: 'Observability',
label: 'Logs',
items: [
{ id: 'reaction-logs', label: 'Reaction Logs', icon: '📜' },
{ id: 'request-logs', label: 'Request Logs', icon: '📝' },
{ 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: '🏠' }],
label: 'Admin',
items: [
{ id: 'api-keys', label: 'API Keys', icon: '⚿' },
{ id: 'health', label: 'System', icon: '●' },
],
},
]
/** Find current nav label for mobile header */
function getPageLabel(page: Page): string {
for (const g of navGroups) {
for (const item of g.items) {
if (item.id === page) return item.label
}
}
return 'OGraph'
}
export default function Layout({ page, onPageChange, children }: Props) {
const [sidebarOpen, setSidebarOpen] = useState(false)
// Close sidebar on page change (mobile)
const handleNav = (p: Page) => {
onPageChange(p)
setSidebarOpen(false)
}
// Close sidebar on ESC
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSidebarOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
// Lock body scroll when sidebar is open on mobile
useEffect(() => {
if (sidebarOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => { document.body.style.overflow = '' }
}, [sidebarOpen])
return (
<div className="flex h-screen bg-gray-950 text-gray-100">
<div className="flex h-screen bg-surface-0 text-gray-200 grain">
{/* Mobile backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/60 z-30 lg:hidden animate-fade-in"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* 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>
<aside
className={`
fixed inset-y-0 left-0 z-40 w-56 bg-surface-1 border-r border-white/[0.06] flex flex-col shrink-0
transform transition-transform duration-200 ease-out
lg:relative lg:translate-x-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}
>
{/* Logo */}
<div className="px-5 py-5 border-b border-white/[0.06] flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-accent to-blue-400 flex items-center justify-center text-white text-xs font-bold shadow-lg shadow-accent/20">
G
</div>
<nav className="flex-1 overflow-y-auto p-3">
<div>
<h1 className="text-sm font-bold text-white tracking-tight">OGraph</h1>
<p className="text-[10px] text-gray-500 font-medium">Event Engine</p>
</div>
</div>
{/* Close button (mobile only) */}
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-1 rounded-md text-gray-500 hover:text-gray-300 hover:bg-white/[0.06] transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-3 px-2.5">
{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">
<div key={group.label} className="mb-3">
<div className="px-2.5 py-1.5 text-[10px] font-semibold text-gray-500 uppercase tracking-[0.08em]">
{group.label}
</div>
{group.items.map((item) => (
{group.items.map((item) => {
const isActive = page === item.id
return (
<button
key={item.id}
onClick={() => onPageChange(item.id)}
onClick={() => handleNav(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'
w-full text-left px-2.5 py-[7px] rounded-md mb-0.5 flex items-center gap-2.5 text-[13px] font-medium transition-all
${isActive
? 'bg-accent/[0.12] text-accent-glow'
: 'text-gray-400 hover:bg-white/[0.04] hover:text-gray-200'
}
`}
>
<span className="text-lg">{item.icon}</span>
<span className="text-sm">{item.label}</span>
<span className={`text-xs w-4 text-center ${isActive ? 'text-accent-glow' : 'text-gray-600'}`}>
{item.icon}
</span>
<span>{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>
{/* Footer */}
<div className="px-4 py-3 border-t border-white/[0.06]">
<div className="flex items-center gap-2 text-[11px] text-gray-500">
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse-slow" />
<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 area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile top bar */}
<header className="lg:hidden flex items-center gap-3 px-4 py-3 bg-surface-1 border-b border-white/[0.06] shrink-0">
<button
onClick={() => setSidebarOpen(true)}
className="p-1.5 rounded-md text-gray-400 hover:text-gray-200 hover:bg-white/[0.06] transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded bg-gradient-to-br from-accent to-blue-400 flex items-center justify-center text-white text-[9px] font-bold">
G
</div>
<span className="text-sm font-semibold text-white">{getPageLabel(page)}</span>
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-y-auto bg-surface-0">
<div className="p-4 sm:p-6 lg:p-8 max-w-[1440px] mx-auto animate-fade-in">{children}</div>
</main>
</div>
</div>
)
}

View File

@ -19,13 +19,14 @@ export default function ObjectDefs() {
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">
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">Object Definitions</h2>
<div className="bg-surface-1 rounded-xl overflow-hidden border border-white/[0.06]">
{data.length === 0 ? (
<EmptyState message="No object definitions found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead className="bg-surface-3/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
@ -37,14 +38,15 @@ export default function ObjectDefs() {
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-white/[0.02]'
} hover:bg-surface-3/60`}
>
<td className="px-4 py-3 font-mono text-gray-100">{def.name}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>

View File

@ -71,9 +71,9 @@ export default function Objects() {
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-5">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold">Objects</h2>
<h2 className="text-lg font-semibold text-white tracking-tight">Objects</h2>
{!showCreate ? (
<button
onClick={() => { setShowCreate(true); setCreateType(types[0] || '') }}
@ -87,7 +87,7 @@ export default function Objects() {
<select
value={createType}
onChange={(e) => setCreateType(e.target.value)}
className="px-3 py-1.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-blue-500 focus:outline-none"
className="px-3 py-1.5 bg-surface-3 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-blue-500 focus:outline-none"
>
{types.map((t) => (
<option key={t} value={t}>{t}</option>
@ -116,7 +116,7 @@ export default function Objects() {
</div>
<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">
<Listbox.Button className="w-full px-4 py-2 bg-surface-3 border border-gray-700 rounded-lg text-left focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all">
<span className={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">
@ -128,7 +128,7 @@ export default function Objects() {
</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.Options className="absolute z-10 mt-1 w-full bg-surface-3 border border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto focus:outline-none">
<Listbox.Option
value=""
className={({ active }) =>
@ -156,12 +156,13 @@ export default function Objects() {
</div>
</Listbox>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
<div className="bg-surface-1 rounded-xl overflow-hidden border border-white/[0.06]">
{data.length === 0 ? (
<EmptyState message="No objects found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead className="bg-surface-3/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">
@ -177,8 +178,8 @@ export default function Objects() {
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-white/[0.02]'
} hover:bg-surface-3/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">
@ -191,6 +192,7 @@ export default function Objects() {
))}
</tbody>
</table>
</div>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>

View File

@ -32,7 +32,7 @@ export default function ProjectionDefs() {
return (
<div className="max-w-5xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Projection Definitions</h2>
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">Projection Definitions</h2>
{data.length === 0 ? (
<EmptyState message="No projection definitions found" />
) : (
@ -40,7 +40,7 @@ export default function ProjectionDefs() {
{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"
className="bg-surface-1 rounded-xl p-5 border border-white/[0.06] hover:border-white/[0.1] transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div>
@ -57,7 +57,7 @@ export default function ProjectionDefs() {
</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">
<div className="mt-4 space-y-3 text-sm bg-surface-3/30 rounded-lg p-4 border border-gray-700/50">
{def.sources && (
<div>
<span className="text-gray-400 font-medium">Sources:</span>
@ -81,7 +81,7 @@ export default function ProjectionDefs() {
{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">
<pre className="mt-1.5 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-white/[0.06]">
{JSON.stringify(def.params, null, 2)
.split('\n')
.map((line, i) => {
@ -106,7 +106,7 @@ export default function ProjectionDefs() {
{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">
<pre className="mt-1.5 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-white/[0.06] text-purple-300">
{JSON.stringify(def.value_schema, null, 2)}
</pre>
</div>
@ -114,7 +114,7 @@ export default function ProjectionDefs() {
{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">
<pre className="mt-1.5 p-3 bg-gray-950 rounded-lg text-xs overflow-x-auto border border-white/[0.06] text-blue-300">
{JSON.stringify(def.initial_value, null, 2)}
</pre>
</div>

View File

@ -81,14 +81,14 @@ export default function Projections() {
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">
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">Query Projections</h2>
<div className="bg-surface-1 rounded-xl p-6 space-y-6 border border-white/[0.06]">
{/* Projection selector with Listbox */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Projection</label>
<Listbox value={selectedDef} onChange={handleSelectDef}>
<div className="relative">
<Listbox.Button className="w-full px-4 py-2.5 bg-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">
<Listbox.Button className="w-full px-4 py-2.5 bg-surface-3 border border-gray-700 rounded-lg text-left focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all">
<span className={selectedDef ? 'text-gray-100' : 'text-gray-500'}>
{selectedDef?.name || 'Select a projection...'}
</span>
@ -102,7 +102,7 @@ export default function Projections() {
</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.Options className="absolute z-10 mt-1 w-full bg-surface-3 border border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto focus:outline-none">
{defs.map((d) => (
<Listbox.Option
key={d.name}
@ -133,7 +133,7 @@ export default function Projections() {
)}
</span>
</div>
<div className="space-y-3 bg-gray-800/30 rounded-lg p-4 border border-gray-700/50">
<div className="space-y-3 bg-surface-3/30 rounded-lg p-4 border border-gray-700/50">
{Object.entries(selectedDef.params).map(([key, schema]) => (
<div key={key}>
<label className="block text-xs font-medium text-gray-500 mb-1.5">
@ -150,11 +150,12 @@ export default function Projections() {
objects={objects}
objectsByType={objectsByType}
objectType={schema.object_type}
paramName={key}
/>
) : (
<input
type={schema.type === 'number' ? 'number' : 'text'}
className="w-full px-3 py-2 bg-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"
className="w-full px-3 py-2 bg-surface-3 border border-white/[0.08] rounded-lg focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/30 transition-all"
placeholder={`Enter ${schema.type} value...`}
value={params[key] || ''}
onChange={(e) => setParams({ ...params, [key]: e.target.value })}
@ -168,7 +169,7 @@ export default function Projections() {
{/* Projection info */}
{selectedDef && (
<div className="text-xs space-y-2 bg-gray-800/20 rounded-lg p-4 border border-gray-700/30">
<div className="text-xs space-y-2 bg-surface-3/20 rounded-lg p-4 border border-gray-700/30">
<div>
<span className="text-gray-500 font-medium">sources:</span>
<div className="mt-1 space-y-2">
@ -215,7 +216,7 @@ export default function Projections() {
{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">
<pre className="p-4 bg-gray-950 rounded-lg overflow-x-auto text-sm text-green-300 border border-white/[0.06]">
{JSON.stringify(result, null, 2)}
</pre>
</div>
@ -232,29 +233,41 @@ function RefCombobox({
objects,
objectsByType,
objectType,
paramName,
}: {
value: string
onChange: (v: string) => void
objects: Array<{ id: string; type: string }>
objectsByType: Record<string, string[]>
objectType?: string
paramName?: string
}) {
const [query, setQuery] = useState('')
// Filter objects by object_type if specified
// Filter objects by object_type if specified, or infer from param name
// Convention: param name often matches the object type (e.g. param "agent" → type "agent")
const effectiveObjectType = useMemo(() => {
if (objectType) return objectType
if (paramName) {
const types = new Set(objects.map(o => o.type))
if (types.has(paramName)) return paramName
}
return undefined
}, [objectType, paramName, objects])
const relevantObjects = useMemo(() => {
if (!objectType) return objects
return objects.filter((o) => o.type === objectType)
}, [objects, objectType])
if (!effectiveObjectType) return objects
return objects.filter((o) => o.type === effectiveObjectType)
}, [objects, effectiveObjectType])
const relevantByType = useMemo(() => {
if (!objectType) return objectsByType
if (!effectiveObjectType) return objectsByType
const filtered: Record<string, string[]> = {}
if (objectsByType[objectType]) {
filtered[objectType] = objectsByType[objectType]
if (objectsByType[effectiveObjectType]) {
filtered[effectiveObjectType] = objectsByType[effectiveObjectType]
}
return filtered
}, [objectsByType, objectType])
}, [objectsByType, effectiveObjectType])
const filtered = useMemo(() => {
if (!query) return relevantObjects
@ -267,7 +280,7 @@ function RefCombobox({
<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"
className="w-full px-3 py-2 pr-10 bg-surface-3 border border-white/[0.08] rounded-lg focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/30 transition-all"
placeholder="Type object ID or select..."
onChange={(e) => setQuery(e.target.value)}
displayValue={(val: string) => val}
@ -282,13 +295,13 @@ function RefCombobox({
</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">
<Combobox.Options className="absolute z-10 mt-1 w-full bg-surface-3 border border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto focus:outline-none">
{Object.entries(relevantByType).map(([type, ids]) => {
const filteredIds = ids.filter((id) => !query || id.toLowerCase().includes(query.toLowerCase()))
if (filteredIds.length === 0) return null
return (
<div key={type}>
<div className="sticky top-0 px-3 py-1.5 text-xs font-medium text-gray-500 bg-gray-850 border-b border-gray-700">
<div className="sticky top-0 px-3 py-1.5 text-xs font-medium text-gray-500 bg-surface-3 border-b border-white/[0.06]">
{type}
</div>
{filteredIds.map((id) => (

View File

@ -64,10 +64,10 @@ export default function ReactionLogs() {
return (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Reaction Logs</h2>
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">Reaction Logs</h2>
{/* Filter */}
<div className="bg-gray-900/50 backdrop-blur rounded-lg border border-gray-800 p-4 mb-6">
<div className="bg-surface-1 rounded-lg border border-white/[0.06] 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>
@ -76,7 +76,7 @@ export default function ReactionLogs() {
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"
className="px-3 py-2 bg-surface-3 border border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-40"
/>
</div>
<button
@ -103,13 +103,13 @@ export default function ReactionLogs() {
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
<div className="bg-surface-1 rounded-xl overflow-hidden border border-white/[0.06]">
{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">
<table className="w-full min-w-[800px]">
<thead className="bg-surface-3/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
@ -151,8 +151,8 @@ export default function ReactionLogs() {
<tr
key={log.id}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-white/[0.02]'
} hover:bg-surface-3/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>
@ -184,7 +184,7 @@ export default function ReactionLogs() {
{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">
<pre className="mt-2 p-2 bg-surface-3 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)}

View File

@ -41,13 +41,14 @@ export default function Reactions() {
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">
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">Reactions</h2>
<div className="bg-surface-1 rounded-xl overflow-hidden border border-white/[0.06]">
{data.length === 0 ? (
<EmptyState message="No reactions found" />
) : (
<table className="w-full">
<thead className="bg-gray-800/80 border-b border-gray-700">
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead className="bg-surface-3/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">
@ -73,8 +74,8 @@ export default function Reactions() {
<tr
key={i}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-white/[0.02]'
} hover:bg-surface-3/60`}
>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{reaction.id}</td>
<td className="px-4 py-3">
@ -121,6 +122,7 @@ export default function Reactions() {
))}
</tbody>
</table>
</div>
)}
<Pagination total={total} limit={limit} offset={offset} onPageChange={setOffset} onLimitChange={setLimit} />
</div>

View File

@ -60,10 +60,10 @@ export default function RequestLogs() {
return (
<div className="max-w-7xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Request Logs</h2>
<h2 className="text-lg font-semibold text-white tracking-tight mb-5">Request Logs</h2>
{/* Filter */}
<div className="bg-gray-900/50 backdrop-blur rounded-lg border border-gray-800 p-4 mb-6">
<div className="bg-surface-1 rounded-lg border border-white/[0.06] 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>
@ -99,12 +99,12 @@ export default function RequestLogs() {
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur rounded-lg overflow-hidden border border-gray-800">
<div className="bg-surface-1 rounded-xl overflow-hidden border border-white/[0.06]">
{data.length === 0 ? (
<EmptyState message="No request logs found" />
) : (
<div className="overflow-x-auto">
<table className="w-full">
<table className="w-full min-w-[800px]">
<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">
@ -138,8 +138,8 @@ export default function RequestLogs() {
<tr
key={log.id}
className={`transition-colors ${
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-gray-850/30'
} hover:bg-gray-800/60`}
i % 2 === 0 ? 'bg-gray-900/30' : 'bg-white/[0.02]'
} hover:bg-surface-3/60`}
>
<td className="px-4 py-3 font-mono text-sm text-gray-300">{log.id}</td>
<td className="px-4 py-3">

View File

@ -1,13 +1,18 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: linear-gradient(to bottom, #050505, #0a0a0f);
color: #f3f4f6;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: #07080a;
color: #e5e7eb;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
@ -15,46 +20,45 @@ body {
height: 100vh;
overflow: hidden;
}
}
/* Custom scrollbar for dark theme */
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1a1a1f;
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #374151;
border-radius: 4px;
background: #2a2d36;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #4b5563;
background: #3a3d46;
}
/* Smooth transitions for interactive elements */
button,
a,
input,
select {
transition: all 0.2s ease;
/* Global transitions */
button, a, input, select, textarea {
transition: all 0.15s ease;
}
/* Custom animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
/* Subtle noise grain overlay */
.grain::after {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
opacity: 0.4;
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
/* Table row animation */
@keyframes rowFadeIn {
from { opacity: 0; transform: translateX(-4px); }
to { opacity: 1; transform: translateX(0); }
}
.animate-row {
animation: rowFadeIn 0.2s ease-out both;
}

View File

@ -2,7 +2,45 @@
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
extend: {
colors: {
surface: {
0: '#07080a',
1: '#0d0f12',
2: '#13161b',
3: '#1a1d24',
4: '#22262f',
},
accent: {
DEFAULT: '#3b82f6',
dim: '#2563eb',
glow: '#60a5fa',
},
mint: {
DEFAULT: '#34d399',
dim: '#059669',
},
},
fontFamily: {
sans: ['"Inter"', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.25s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [],
}