Webapp
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavMenu } from './components/NavMenu'
|
||||
import { FilterBar } from './components/FilterBar'
|
||||
import { LogTimeline, type TimeRange } from './components/LogTimeline'
|
||||
import { LogPreview } from './components/LogPreview'
|
||||
import { ComingSoon } from './components/ComingSoon'
|
||||
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
|
||||
|
||||
const POLL_MS = 2000
|
||||
|
||||
export default function App() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
|
||||
const [range, setRange] = useState<TimeRange | null>(null)
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const [nextStats, nextBuckets, nextLogs] = await Promise.all([
|
||||
fetchStats(),
|
||||
fetchTimeline({ startMs: range?.startMs, endMs: range?.endMs }),
|
||||
fetchLogs({ startMs: range?.startMs, endMs: range?.endMs, limit: 1000 }),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setStats(nextStats)
|
||||
setBuckets(nextBuckets)
|
||||
setLogs(nextLogs)
|
||||
}
|
||||
} catch {
|
||||
// engine not reachable yet; retry on next tick
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
const id = setInterval(poll, POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [range])
|
||||
|
||||
const visibleLogs = filter ? logs.filter((l) => l.text.includes(filter)) : logs
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden">
|
||||
<NavMenu stats={stats} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<FilterBar value={filter} onChange={setFilter} />
|
||||
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} />
|
||||
<LogPreview logs={visibleLogs} />
|
||||
<ComingSoon title="Metrics" />
|
||||
<ComingSoon title="Dashboards" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,7 @@
|
||||
export function ComingSoon({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="border-b border-neutral-800 p-3 text-xs text-neutral-600">
|
||||
{title} — coming soon
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* Placeholder for the syntax-aware pest-parsed filter language described in the
|
||||
* README. For now this only does a plain substring match, applied client-side.
|
||||
*/
|
||||
export function FilterBar({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
const [draft, setDraft] = useState(value)
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex items-center gap-2 border-b border-neutral-800 px-3 py-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onChange(draft)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
|
||||
placeholder='regex("error") and sequence<5 or timestamp>12:00'
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { LogEntry } from '../lib/api'
|
||||
|
||||
export function LogPreview({ logs }: { logs: LogEntry[] }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead className="sticky top-0 bg-neutral-950 text-neutral-500">
|
||||
<tr>
|
||||
<th className="w-16 px-3 py-1 font-normal">Seq</th>
|
||||
<th className="w-40 px-3 py-1 font-normal">Timestamp</th>
|
||||
<th className="px-3 py-1 font-normal">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.sequence} className="border-t border-neutral-900 hover:bg-neutral-900/60">
|
||||
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">{log.sequence}</td>
|
||||
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">
|
||||
{new Date(log.timestamp_ms).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="px-3 py-1 text-neutral-200">{log.text}</td>
|
||||
</tr>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-6 text-center text-neutral-600">
|
||||
No logs yet — pipe something into ottyl.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { TimelineBucket } from '../lib/api'
|
||||
|
||||
export interface TimeRange {
|
||||
startMs: number
|
||||
endMs: number
|
||||
}
|
||||
|
||||
export function LogTimeline({
|
||||
buckets,
|
||||
range,
|
||||
onSelectRange,
|
||||
}: {
|
||||
buckets: TimelineBucket[]
|
||||
range: TimeRange | null
|
||||
onSelectRange: (range: TimeRange | null) => void
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [dragStartX, setDragStartX] = useState<number | null>(null)
|
||||
const [dragCurrentX, setDragCurrentX] = useState<number | null>(null)
|
||||
|
||||
const maxCount = Math.max(1, ...buckets.map((b) => b.count))
|
||||
const domainStart = range?.startMs ?? buckets[0]?.start_ms ?? 0
|
||||
const domainEnd = range?.endMs ?? Date.now()
|
||||
const domainSpan = Math.max(1, domainEnd - domainStart)
|
||||
|
||||
function xToMs(x: number) {
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return domainStart
|
||||
const ratio = Math.min(1, Math.max(0, (x - rect.left) / rect.width))
|
||||
return domainStart + ratio * domainSpan
|
||||
}
|
||||
|
||||
function handleMouseDown(e: React.MouseEvent) {
|
||||
setDragStartX(e.clientX)
|
||||
setDragCurrentX(e.clientX)
|
||||
}
|
||||
function handleMouseMove(e: React.MouseEvent) {
|
||||
if (dragStartX !== null) setDragCurrentX(e.clientX)
|
||||
}
|
||||
function handleMouseUp() {
|
||||
if (dragStartX !== null && dragCurrentX !== null && Math.abs(dragCurrentX - dragStartX) > 3) {
|
||||
const a = xToMs(Math.min(dragStartX, dragCurrentX))
|
||||
const b = xToMs(Math.max(dragStartX, dragCurrentX))
|
||||
onSelectRange({ startMs: a, endMs: b })
|
||||
}
|
||||
setDragStartX(null)
|
||||
setDragCurrentX(null)
|
||||
}
|
||||
|
||||
const dragging = dragStartX !== null && dragCurrentX !== null
|
||||
const selRect = dragging
|
||||
? {
|
||||
left: Math.min(dragStartX!, dragCurrentX!),
|
||||
width: Math.abs(dragCurrentX! - dragStartX!),
|
||||
}
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="border-b border-neutral-800 p-3">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-neutral-500">
|
||||
<span>Timeline</span>
|
||||
{range && (
|
||||
<button className="underline hover:text-neutral-300" onClick={() => onSelectRange(null)}>
|
||||
Clear selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-24 w-full cursor-crosshair select-none rounded bg-neutral-900"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={() => {
|
||||
setDragStartX(null)
|
||||
setDragCurrentX(null)
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full items-end gap-px overflow-hidden px-1">
|
||||
{buckets.map((b) => (
|
||||
<div
|
||||
key={b.start_ms}
|
||||
className="min-w-[2px] flex-1 rounded-t bg-sky-500/70"
|
||||
style={{ height: `${(b.count / maxCount) * 100}%` }}
|
||||
title={`${new Date(b.start_ms).toLocaleTimeString()} — ${b.count}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{selRect && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 h-full border border-sky-400 bg-sky-400/20"
|
||||
style={{ left: selRect.left - (containerRef.current?.getBoundingClientRect().left ?? 0), width: selRect.width }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Stats } from '../lib/api'
|
||||
|
||||
export function NavMenu({ stats }: { stats: Stats | null }) {
|
||||
return (
|
||||
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-neutral-800 p-3">
|
||||
<div>
|
||||
<div className="mb-4 text-lg font-semibold text-neutral-100">ottyl</div>
|
||||
<nav className="flex flex-col gap-1 text-sm text-neutral-400">
|
||||
<span className="rounded bg-neutral-800 px-2 py-1 text-neutral-100">Dashboard</span>
|
||||
<span className="cursor-not-allowed px-2 py-1 opacity-50" title="Coming soon">
|
||||
Presets
|
||||
</span>
|
||||
<span className="cursor-not-allowed px-2 py-1 opacity-50" title="Coming soon">
|
||||
Settings
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500">
|
||||
{stats ? (
|
||||
<>
|
||||
<div>{stats.log_count} logs</div>
|
||||
<div>seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}</div>
|
||||
</>
|
||||
) : (
|
||||
<div>connecting…</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-neutral-950 text-neutral-200;
|
||||
font: 14px/1.5 ui-monospace, Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface LogEntry {
|
||||
sequence: number
|
||||
timestamp_ms: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface TimelineBucket {
|
||||
start_ms: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
log_count: number
|
||||
first_sequence: number | null
|
||||
last_sequence: number | null
|
||||
first_timestamp_ms: number | null
|
||||
last_timestamp_ms: number | null
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path)
|
||||
if (!res.ok) {
|
||||
throw new Error(`${path} failed: ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function fetchLogs(params: {
|
||||
sinceSequence?: number
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
limit?: number
|
||||
}): Promise<LogEntry[]> {
|
||||
const search = new URLSearchParams()
|
||||
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
|
||||
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
|
||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||
if (params.limit !== undefined) search.set('limit', String(params.limit))
|
||||
return getJson(`/api/logs?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchTimeline(params: {
|
||||
startMs?: number
|
||||
endMs?: number
|
||||
bucketMs?: number
|
||||
}): Promise<TimelineBucket[]> {
|
||||
const search = new URLSearchParams()
|
||||
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
|
||||
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||
if (params.bucketMs !== undefined) search.set('bucket_ms', String(params.bucketMs))
|
||||
return getJson(`/api/timeline?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function fetchStats(): Promise<Stats> {
|
||||
return getJson('/api/stats')
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user