This commit is contained in:
Hugh Kaznowski
2026-09-05 12:23:46 +01:00
parent 818f8d09ea
commit 68548911bc
34 changed files with 3262 additions and 4 deletions
+7
View File
@@ -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>
)
}
+32
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
+99
View File
@@ -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>
)
}
+30
View File
@@ -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>
)
}