Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/server/src/client/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export type EventsParams = {
event?: string
repo?: string
entityKey?: string
from?: number
to?: number
/** Relative window in seconds; the caller derives `from` at fetch time. */
windowSeconds?: number
}

export type AgentWorkItem = {
Expand Down Expand Up @@ -164,6 +168,8 @@ export const api = {
if (params.event) qs.set("event", params.event)
if (params.repo) qs.set("repo", params.repo)
if (params.entityKey) qs.set("entityKey", params.entityKey)
if (params.from != null) qs.set("from", String(params.from))
if (params.to != null) qs.set("to", String(params.to))

const res = await fetch(`/api/events?${qs.toString()}`)
if (!res.ok) throw new Error(`Failed to fetch events: ${res.status}`)
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/client/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@ export function useSession() {
}

export function useEvents(params: EventsParams = {}, options: { enabled?: boolean } = {}) {
const { windowSeconds, ...rest } = params
return useQuery({
queryKey: ["events", params],
queryFn: () => api.getEvents(params),
// Derive `from` at fetch time so each auto-refetch keeps a window relative
// to the current moment rather than the timestamp captured at render.
queryFn: () =>
api.getEvents({
...rest,
from: windowSeconds ? Math.floor(Date.now() / 1000) - windowSeconds : undefined,
}),
enabled: options.enabled ?? true,
placeholderData: keepPreviousData,
refetchInterval: 10_000,
Expand Down
40 changes: 37 additions & 3 deletions apps/server/src/client/pages/events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,25 @@ import {
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"

const STATUS_OPTIONS = ["all", "pending", "admitted", "settled", "completed", "failed", "skipped", "d:boot"] as const
const PAGE_SIZES = [10, 25, 50] as const

const TIME_RANGES = [
{ value: "all", label: "All time", seconds: 0 },
{ value: "1h", label: "Last hour", seconds: 60 * 60 },
{ value: "6h", label: "Last 6 hours", seconds: 6 * 60 * 60 },
{ value: "24h", label: "Last 24 hours", seconds: 24 * 60 * 60 },
{ value: "7d", label: "Last 7 days", seconds: 7 * 24 * 60 * 60 },
] as const

type TimeRangeValue = (typeof TIME_RANGES)[number]["value"]

const isTimeRangeValue = (value: string): value is TimeRangeValue => TIME_RANGES.some((r) => r.value === value)

export default function EventsPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
Expand All @@ -41,12 +54,18 @@ export default function EventsPage() {
const limit = Number(searchParams.get("limit")) || 25
const statusFilter = searchParams.get("status") ?? "all"
const repoFilter = searchParams.get("repo") ?? ""
const timeRangeParam = searchParams.get("timeRange") ?? "all"
const timeRange: TimeRangeValue = isTimeRangeValue(timeRangeParam) ? timeRangeParam : "all"
const windowSeconds = TIME_RANGES.find((r) => r.value === timeRange)?.seconds || undefined

const { data, isLoading, isError, dataUpdatedAt, isFetching, refetch } = useEvents({
page,
limit,
status: statusFilter !== "all" ? statusFilter : undefined,
repo: repoFilter || undefined,
// Pass the window size, not an absolute cutoff: `from` is derived at fetch
// time so each auto-refetch keeps the window relative to the current moment.
windowSeconds,
})

// Sync input with URL param when navigating
Expand All @@ -69,6 +88,7 @@ export default function EventsPage() {
const setPage = (p: number) => updateParams({ page: String(p) })
const setLimit = (l: number) => updateParams({ limit: String(l), page: "1" })
const setStatus = (s: string) => updateParams({ status: s === "all" ? null : s, page: "1" })
const setTimeRange = (r: string) => updateParams({ timeRange: r === "all" ? null : r, page: "1" })

const applyRepoFilter = () => {
updateParams({ repo: repoInput || null, page: "1" })
Expand Down Expand Up @@ -96,7 +116,7 @@ export default function EventsPage() {
}

const pagination = data?.pagination
const hasActiveFilters = statusFilter !== "all" || !!repoFilter
const hasActiveFilters = statusFilter !== "all" || !!repoFilter || timeRange !== "all"

return (
<div className="space-y-4">
Expand Down Expand Up @@ -185,13 +205,27 @@ export default function EventsPage() {
</button>
)}
</div>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger size="sm" className="text-xs">
<SelectValue>{(value) => TIME_RANGES.find((r) => r.value === value)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{TIME_RANGES.map((r) => (
<SelectItem key={r.value} value={r.value} className="text-xs">
{r.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{hasActiveFilters && (
<Button
variant="ghost"
size="xs"
onClick={() => {
setRepoInput("")
updateParams({ status: null, repo: null, page: "1" })
updateParams({ status: null, repo: null, timeRange: null, page: "1" })
}}
>
Clear filters
Expand Down Expand Up @@ -300,7 +334,7 @@ export default function EventsPage() {
size="xs"
onClick={() => {
setRepoInput("")
updateParams({ status: null, repo: null, page: "1" })
updateParams({ status: null, repo: null, timeRange: null, page: "1" })
}}
>
Clear filters
Expand Down
89 changes: 89 additions & 0 deletions apps/server/src/routes/__tests__/events-time-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Hono } from "hono"
import { afterEach, describe, expect, it } from "vitest"
import { testDb } from "@/__tests__/test-db"
import * as schema from "@/db/schema"
import type { AuthEnv } from "@/types"
import eventsRouter from "../events"

const closes: Array<() => void> = []
afterEach(() => {
for (const close of closes.splice(0)) close()
})

async function fixture() {
const { db, close } = await testDb()
closes.push(close)
const now = Math.floor(Date.now() / 1000)
const at = (secondsAgo: number) => new Date((now - secondsAgo) * 1000)
await db.insert(schema.webhookEvents).values([
{
id: "recent",
entityKey: "acme/app#1",
event: "issues",
deliveryId: "recent",
payload: "{}",
createdAt: at(30 * 60),
},
{
id: "hours",
entityKey: "acme/app#2",
event: "issues",
deliveryId: "hours",
payload: "{}",
createdAt: at(3 * 60 * 60),
},
{
id: "old",
entityKey: "acme/app#3",
event: "issues",
deliveryId: "old",
payload: "{}",
createdAt: at(3 * 24 * 60 * 60),
},
])
const app = new Hono<AuthEnv>()
.use(async (c, next) => {
c.set("db", db)
c.set("user", { id: "operator" } as AuthEnv["Variables"]["user"])
await next()
})
.route("/", eventsRouter)
const list = async (query = "") => {
const res = await app.request(`/${query}`)
expect(res.status).toBe(200)
const body = (await res.json()) as { data: Array<{ id: string }>; pagination: { total: number } }
return body
}
return { list, now }
}

describe("GET /events time range filtering", () => {
it("returns all events without a from/to window", async () => {
const f = await fixture()
const body = await f.list()
expect(body.pagination.total).toBe(3)
})

it("filters to events after `from`", async () => {
const f = await fixture()
const oneHourAgo = f.now - 60 * 60
const body = await f.list(`?from=${oneHourAgo}`)
expect(body.data.map((e) => e.id)).toEqual(["recent"])
expect(body.pagination.total).toBe(1)
})

it("filters within a from/to window", async () => {
const f = await fixture()
const sixHoursAgo = f.now - 6 * 60 * 60
const oneHourAgo = f.now - 60 * 60
const body = await f.list(`?from=${sixHoursAgo}&to=${oneHourAgo}`)
expect(body.data.map((e) => e.id)).toEqual(["hours"])
expect(body.pagination.total).toBe(1)
})

it("ignores non-numeric from/to values", async () => {
const f = await fixture()
const body = await f.list("?from=notanumber")
expect(body.pagination.total).toBe(3)
})
})
10 changes: 9 additions & 1 deletion apps/server/src/routes/events/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, like, or, sql } from "drizzle-orm"
import { and, desc, eq, gte, like, lte, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { agentWorkItems, githubDiscussionObligations, webhookEvents } from "@/db/schema"
import { getSessionController } from "@/lib/containers/session-controller"
Expand Down Expand Up @@ -62,6 +62,8 @@ const router = new Hono<AuthEnv>()
const event = c.req.query("event")
const repo = c.req.query("repo")
const entityKey = c.req.query("entityKey")
const from = Number(c.req.query("from")) || undefined
const to = Number(c.req.query("to")) || undefined

const conditions = []
if (status) {
Expand All @@ -86,6 +88,12 @@ const router = new Hono<AuthEnv>()
if (entityKey) {
conditions.push(eq(webhookEvents.entityKey, entityKey))
}
if (from) {
conditions.push(gte(webhookEvents.createdAt, new Date(from * 1000)))
}
if (to) {
conditions.push(lte(webhookEvents.createdAt, new Date(to * 1000)))
}

const where = conditions.length > 0 ? and(...conditions) : undefined
const offset = (page - 1) * limit
Expand Down
Loading