diff --git a/apps/server/src/client/lib/api.ts b/apps/server/src/client/lib/api.ts index a8dfd9e..2718e77 100644 --- a/apps/server/src/client/lib/api.ts +++ b/apps/server/src/client/lib/api.ts @@ -14,6 +14,8 @@ export type EventsParams = { entityKey?: string from?: number to?: number + sortBy?: string + sortDir?: "asc" | "desc" /** Relative window in seconds; the caller derives `from` at fetch time. */ windowSeconds?: number } @@ -135,6 +137,7 @@ export type EventStats = { stuck: number skipped: number last24h: number + previous24h: number maintenance: { cron: string scheduledAt: string @@ -170,6 +173,8 @@ export const api = { 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)) + if (params.sortBy) qs.set("sortBy", params.sortBy) + if (params.sortDir) qs.set("sortDir", params.sortDir) const res = await fetch(`/api/events?${qs.toString()}`) if (!res.ok) throw new Error(`Failed to fetch events: ${res.status}`) diff --git a/apps/server/src/client/pages/container-detail.tsx b/apps/server/src/client/pages/container-detail.tsx index 8a17c0b..543edad 100644 --- a/apps/server/src/client/pages/container-detail.tsx +++ b/apps/server/src/client/pages/container-detail.tsx @@ -35,6 +35,7 @@ import { useWorkspaceHealth, } from "@/client/lib/queries" import { GitHubLink } from "@/components/github-link" +import { StatusDot } from "@/components/run-status" import { StatusBadge } from "@/components/status-badge" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { @@ -69,21 +70,6 @@ import { // Status helpers // --------------------------------------------------------------------------- -function StatusDot({ status }: { status: string }) { - const styles: Record = { - working: "bg-yellow-500 animate-pulse", - busy: "bg-yellow-500 animate-pulse", - idle: "bg-green-500", - blocked: "bg-red-500", - failed: "bg-destructive", - interrupted: "bg-destructive", - cleanup_pending: "bg-destructive", - sync_unavailable: "bg-amber-500", - historical: "bg-muted-foreground/50", - } - return -} - function modelLabel(model: string): string { if (model.includes("claude-opus-4.8")) return "Claude Opus 4.8" if (model.includes("grok-4.3")) return "Grok 4.3" diff --git a/apps/server/src/client/pages/dashboard.tsx b/apps/server/src/client/pages/dashboard.tsx index 68f03d4..379e937 100644 --- a/apps/server/src/client/pages/dashboard.tsx +++ b/apps/server/src/client/pages/dashboard.tsx @@ -1,4 +1,13 @@ -import { ArrowRight, CheckCircle, CircleDashed, Clock, Hourglass, Warning } from "@phosphor-icons/react" +import { + ArrowDown, + ArrowRight, + ArrowUp, + CheckCircle, + CircleDashed, + Clock, + Hourglass, + Warning, +} from "@phosphor-icons/react" import { Link, useNavigate } from "react-router-dom" import { formatTimeAgo, repoGitHubUrl } from "@/client/lib/format" import { useEventStats, useEvents } from "@/client/lib/queries" @@ -10,6 +19,26 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +function TrendBadge({ current, previous }: { current: number; previous: number }) { + if (previous === 0 && current === 0) return null + const delta = current - previous + if (delta === 0) return null + const pct = previous > 0 ? Math.round((Math.abs(delta) / previous) * 100) : null + const rising = delta > 0 + return ( + + {rising ? : } + {pct !== null ? `${pct}%` : `+${Math.abs(delta)}`} + + ) +} + function StatsCards() { const { data: stats, isLoading, isError, dataUpdatedAt, isFetching, refetch } = useEventStats() @@ -118,8 +147,9 @@ function StatsCards() { {stats?.skipped ?? 0} skipped - + {stats?.last24h ?? 0} events in the last 24h + {stats?.total ?? 0} total retained events diff --git a/apps/server/src/client/pages/events.tsx b/apps/server/src/client/pages/events.tsx index 821ca29..2234ada 100644 --- a/apps/server/src/client/pages/events.tsx +++ b/apps/server/src/client/pages/events.tsx @@ -1,4 +1,4 @@ -import { CaretLeft, CaretRight, Funnel, ListBullets, MagnifyingGlass, Trash, X } from "@phosphor-icons/react" +import { Funnel, ListBullets, MagnifyingGlass, Trash, X } from "@phosphor-icons/react" import { useEffect, useState } from "react" import { useNavigate, useSearchParams } from "react-router-dom" import { toast } from "sonner" @@ -6,6 +6,8 @@ import { entityGitHubUrl, formatTimeAgo, repoGitHubUrl } from "@/client/lib/form import { useClearEvents, useEventStats, useEvents, useEventsGrouped } from "@/client/lib/queries" import { GitHubLink } from "@/components/github-link" import { LastUpdated } from "@/components/last-updated" +import { PageSizeSelector, PaginationFooter } from "@/components/pagination" +import { SortableTableHead, type SortDir, toggleSort } from "@/components/sortable-table-head" import { StatusBadge } from "@/components/status-badge" import { AlertDialog, @@ -26,7 +28,6 @@ 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 }, @@ -57,12 +58,16 @@ export default function EventsPage() { 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 sortBy = searchParams.get("sortBy") ?? "time" + const sortDir = (searchParams.get("sortDir") as SortDir) ?? "desc" const { data, isLoading, isError, dataUpdatedAt, isFetching, refetch } = useEvents({ page, limit, status: statusFilter !== "all" ? statusFilter : undefined, repo: repoFilter || undefined, + sortBy: sortBy !== "time" ? sortBy : undefined, + sortDir: sortDir !== "desc" ? sortDir : 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, @@ -89,6 +94,10 @@ export default function EventsPage() { 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 handleSort = (column: string) => { + const next = toggleSort({ sortBy, sortDir }, column, "desc") + updateParams({ sortBy: next.sortBy, sortDir: next.sortDir, page: "1" }) + } const applyRepoFilter = () => { // No-op when the committed value is unchanged so a plain focus/blur doesn't @@ -124,7 +133,7 @@ export default function EventsPage() { const hasActiveFilters = statusFilter !== "all" || !!repoFilter || timeRange !== "all" return ( -
+
{/* Header */}
@@ -239,14 +248,7 @@ export default function EventsPage() { Clear filters )} -
- Per page: - {PAGE_SIZES.map((s) => ( - - ))} -
+
{/* Grouped view */} @@ -353,12 +355,37 @@ export default function EventsPage() { - Event + Entity - Repo - Sender - Status - Time + + + + @@ -406,36 +433,14 @@ export default function EventsPage() { {/* Pagination */} - {pagination && pagination.totalPages > 1 && ( -
- - Showing {(pagination.page - 1) * pagination.limit + 1}– - {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} - -
- - - {pagination.page} / {pagination.totalPages} - - -
-
+ {pagination && ( + )} ) diff --git a/apps/server/src/client/pages/sessions.tsx b/apps/server/src/client/pages/sessions.tsx index a4b4258..6eb4d9d 100644 --- a/apps/server/src/client/pages/sessions.tsx +++ b/apps/server/src/client/pages/sessions.tsx @@ -1,14 +1,4 @@ -import { - CaretDown, - CaretLeft, - CaretRight, - ChatsCircle, - MagnifyingGlass, - Robot, - Stack, - Trash, - X, -} from "@phosphor-icons/react" +import { CaretDown, ChatsCircle, MagnifyingGlass, Robot, Stack, Trash, X } from "@phosphor-icons/react" import { useEffect, useRef, useState } from "react" import { useNavigate, useSearchParams } from "react-router-dom" import type { SessionListItem } from "@/client/lib/api" @@ -18,6 +8,9 @@ import { ClearSessionsFeedback } from "@/components/clear-sessions-feedback" import { GitHubLink } from "@/components/github-link" import { LastUpdated } from "@/components/last-updated" import { NewChatDialog } from "@/components/new-chat-dialog" +import { PageSizeSelector, PaginationFooter } from "@/components/pagination" +import { RunStatusIndicator } from "@/components/run-status" +import { SortableTableHead, type SortDir, toggleSort } from "@/components/sortable-table-head" import { AlertDialog, AlertDialogAction, @@ -45,8 +38,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { chatEntityRepo } from "@/lib/containers/chat-run" import { runStatusLabel, runStatusNotice } from "@/lib/containers/run-status" -const PAGE_SIZES = [10, 25, 50] as const - const STATUS_FILTERS = [ { value: "all", label: "All" }, { value: "working", label: "Working" }, @@ -59,7 +50,7 @@ type SessionStatusFilter = (typeof STATUS_FILTERS)[number]["value"] // Collapse the raw run status into one of the operator-facing filter buckets. // Anything that isn't working/idle/historical (unknown plus the attention -// notices) is surfaced as "Offline", matching the StatusIndicator fallback. +// notices) is surfaced as "Offline", matching the RunStatusIndicator fallback. export function sessionStatusBucket(status: string): Exclude { if (status === "working" || status === "busy") return "working" if (status === "idle") return "idle" @@ -85,48 +76,6 @@ function activitySourceLabel(source: NonNullable{notice.title} - const config: Record = { - working: { - bg: "bg-yellow-50 text-yellow-700 dark:bg-yellow-950/50 dark:text-yellow-300", - dot: "bg-yellow-500 animate-pulse", - label: "Working", - }, - // Legacy API value before display-status rollout - busy: { - bg: "bg-yellow-50 text-yellow-700 dark:bg-yellow-950/50 dark:text-yellow-300", - dot: "bg-yellow-500 animate-pulse", - label: "Working", - }, - idle: { - bg: "bg-green-50 text-green-700 dark:bg-green-950/50 dark:text-green-300", - dot: "bg-green-500", - label: "Idle", - }, - historical: { - bg: "bg-muted text-muted-foreground", - dot: "bg-muted-foreground/50", - label: "Historical", - }, - unknown: { - bg: "bg-gray-50 text-gray-600 dark:bg-gray-900 dark:text-gray-400", - dot: "bg-gray-400", - label: "Offline", - }, - } - const c = config[status] ?? config.unknown - return ( - - - {c.label} - - ) -} - type ClearMode = "all" | "idle" export default function SessionsPage() { @@ -167,6 +116,13 @@ export default function SessionsPage() { const setLimit = (l: number) => updateParams({ limit: String(l), page: "1" }) const setStatus = (s: SessionStatusFilter) => updateParams({ status: s === "all" ? null : s }) + const sortBy = searchParams.get("sortBy") ?? "updatedAt" + const sortDir = (searchParams.get("sortDir") as SortDir) ?? "desc" + const handleSort = (column: string) => { + const next = toggleSort({ sortBy, sortDir }, column, "desc") + updateParams({ sortBy: next.sortBy, sortDir: next.sortDir, page: "1" }) + } + const allSessions = data?.data ?? [] const statusCounts: Record = { all: allSessions.length, @@ -198,18 +154,34 @@ export default function SessionsPage() { if (e.key === "Escape") clearSearchFilter() } - const filtered = sortSessionsByCreatedAt( - allSessions.filter((session: SessionListItem) => { - if (statusFilter !== "all" && sessionStatusBucket(session.status) !== statusFilter) return false - if (!searchFilter) return true - const q = searchFilter.toLowerCase() - return ( - session.entityKey.toLowerCase().includes(q) || - (session.title ?? "").toLowerCase().includes(q) || - (session.agent ?? "").toLowerCase().includes(q) - ) - }), - ) + const filteredUnsorted = allSessions.filter((session: SessionListItem) => { + if (statusFilter !== "all" && sessionStatusBucket(session.status) !== statusFilter) return false + if (!searchFilter) return true + const q = searchFilter.toLowerCase() + return ( + session.entityKey.toLowerCase().includes(q) || + (session.title ?? "").toLowerCase().includes(q) || + (session.agent ?? "").toLowerCase().includes(q) + ) + }) + + const filtered = filteredUnsorted.slice().sort((a, b) => { + const dir = sortDir === "asc" ? 1 : -1 + switch (sortBy) { + case "entity": + return dir * a.entityKey.localeCompare(b.entityKey) + case "status": + return dir * sessionStatusBucket(a.status).localeCompare(sessionStatusBucket(b.status)) + case "agent": + return dir * (a.agent ?? "").localeCompare(b.agent ?? "") + case "sessions": + return dir * (a.sessionCount - b.sessionCount) + case "messages": + return dir * (a.messageCount - b.messageCount) + default: + return dir * (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) + } + }) const pagination = data?.pagination const clearing = clearSessions.isPending @@ -225,7 +197,7 @@ export default function SessionsPage() { } return ( -
+

Agent runs

@@ -358,14 +330,7 @@ export default function SessionsPage() { )}
-
- Per page: - {PAGE_SIZES.map((s) => ( - - ))} -
+
@@ -443,7 +408,7 @@ export default function SessionsPage() { )}
- +
{session.title &&

{session.title}

} {(session.agent || session.model) && ( @@ -488,21 +453,63 @@ export default function SessionsPage() {
- Entity + Latest activity - Agent - Status - - - Sessions - - - - - Msgs - - - Updated + + + + Sessions + + } + sortBy={sortBy} + sortDir={sortDir} + onSort={handleSort} + className="w-[80px] text-center" + /> + + Msgs + + } + sortBy={sortBy} + sortDir={sortDir} + onSort={handleSort} + className="w-[80px] text-center" + /> + @@ -511,8 +518,7 @@ export default function SessionsPage() { const parsed = parseEntityKey(session.entityKey) const chatRepo = chatEntityRepo(session.entityKey) const repoName = parsed ? `${parsed.owner}/${parsed.repo}` : chatRepo - const openDetail = () => - navigate(`/runs/detail?key=${encodeURIComponent(session.entityKey)}`) + const openDetail = () => navigate(`/runs/detail?key=${encodeURIComponent(session.entityKey)}`) return ( - + {session.sessionCount} @@ -617,36 +623,14 @@ export default function SessionsPage() { - {pagination && pagination.totalPages > 1 && ( -
- - Showing {(pagination.page - 1) * pagination.limit + 1}– - {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} - -
- - - {pagination.page} / {pagination.totalPages} - - -
-
+ {pagination && ( + )} ) diff --git a/apps/server/src/components/layout.tsx b/apps/server/src/components/layout.tsx index 94e4860..56415a4 100644 --- a/apps/server/src/components/layout.tsx +++ b/apps/server/src/components/layout.tsx @@ -1,7 +1,7 @@ import { CaretUpDown, House, Lightning, List, Monitor, Moon, Robot, SignOut, Sun } from "@phosphor-icons/react" import { useQueryClient } from "@tanstack/react-query" import { useTheme } from "next-themes" -import { NavLink, Outlet, useNavigate } from "react-router-dom" +import { Link, NavLink, Outlet, useLocation, useNavigate, useSearchParams } from "react-router-dom" import { useSession } from "@/client/lib/queries" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { @@ -170,6 +170,57 @@ function AppSidebar() { ) } +// --------------------------------------------------------------------------- +// Breadcrumbs — derives segments from the current URL path. +// --------------------------------------------------------------------------- + +function Breadcrumbs() { + const location = useLocation() + const [searchParams] = useSearchParams() + + const segments = location.pathname.split("/").filter(Boolean) + + // Build crumb list: [{label, href}] + const crumbs: { label: string; href?: string }[] = [{ label: "Dashboard", href: "/" }] + + if (segments[0] === "events") { + crumbs.push({ label: "Webhook Events", href: "/events" }) + if (segments[1]) { + // /events/:id + const id = segments[1] + crumbs.push({ label: `Event ${id.slice(0, 8)}…` }) + } + } else if (segments[0] === "runs") { + crumbs.push({ label: "Agent Runs", href: "/runs" }) + if (segments.length > 1) { + // /runs/detail?key=... or /runs/:entityKey + const key = searchParams.get("key") ?? (segments[1] !== "detail" ? segments[1] : null) + crumbs.push({ label: key ?? "Run Detail" }) + } + } + + // Only the last segment is non-clickable + return ( + + ) +} + export default function Layout() { return ( @@ -181,7 +232,7 @@ export default function Layout() {
- Outpost +
diff --git a/apps/server/src/components/pagination.tsx b/apps/server/src/components/pagination.tsx new file mode 100644 index 0000000..9f1a8c8 --- /dev/null +++ b/apps/server/src/components/pagination.tsx @@ -0,0 +1,56 @@ +import { CaretLeft, CaretRight } from "@phosphor-icons/react" +import { Button } from "@/components/ui/button" + +const PAGE_SIZES = [10, 25, 50] as const + +interface PaginationFooterProps { + page: number + limit: number + total: number + totalPages: number + onPageChange: (page: number) => void +} + +export function PaginationFooter({ page, limit, total, totalPages, onPageChange }: PaginationFooterProps) { + if (totalPages <= 1) return null + return ( +
+ + Showing {(page - 1) * limit + 1}–{Math.min(page * limit, total)} of {total} + +
+ + + {page} / {totalPages} + + +
+
+ ) +} + +interface PageSizeSelectorProps { + current: number + onChange: (size: number) => void + options?: readonly number[] + className?: string +} + +export function PageSizeSelector({ current, onChange, options = PAGE_SIZES, className = "" }: PageSizeSelectorProps) { + return ( +
+ Per page: + {options.map((s) => ( + + ))} +
+ ) +} diff --git a/apps/server/src/components/run-status.tsx b/apps/server/src/components/run-status.tsx new file mode 100644 index 0000000..d8e9979 --- /dev/null +++ b/apps/server/src/components/run-status.tsx @@ -0,0 +1,88 @@ +import { Badge } from "@/components/ui/badge" +import { runStatusNotice } from "@/lib/containers/run-status" + +// --------------------------------------------------------------------------- +// Shared color map for run/session statuses. Every component in the family +// draws from this single source of truth so colours stay consistent. +// --------------------------------------------------------------------------- + +const RUN_STATUS_COLORS: Record = { + working: { + dot: "bg-yellow-500 animate-pulse", + bg: "bg-yellow-50 text-yellow-700 dark:bg-yellow-950/50 dark:text-yellow-300", + label: "Working", + }, + // Legacy API value before display-status rollout + busy: { + dot: "bg-yellow-500 animate-pulse", + bg: "bg-yellow-50 text-yellow-700 dark:bg-yellow-950/50 dark:text-yellow-300", + label: "Working", + }, + idle: { + dot: "bg-green-500", + bg: "bg-green-50 text-green-700 dark:bg-green-950/50 dark:text-green-300", + label: "Idle", + }, + historical: { + dot: "bg-muted-foreground/50", + bg: "bg-muted text-muted-foreground", + label: "Historical", + }, + blocked: { dot: "bg-red-500", bg: "bg-red-50 text-red-700 dark:bg-red-950/50 dark:text-red-300", label: "Blocked" }, + failed: { + dot: "bg-destructive", + bg: "bg-red-50 text-red-700 dark:bg-red-950/50 dark:text-red-300", + label: "Failed", + }, + interrupted: { + dot: "bg-destructive", + bg: "bg-red-50 text-red-700 dark:bg-red-950/50 dark:text-red-300", + label: "Interrupted", + }, + cleanup_pending: { + dot: "bg-destructive", + bg: "bg-red-50 text-red-700 dark:bg-red-950/50 dark:text-red-300", + label: "Cleanup Pending", + }, + sync_unavailable: { + dot: "bg-amber-500", + bg: "bg-amber-50 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300", + label: "Sync Unavailable", + }, + unknown: { + dot: "bg-gray-400", + bg: "bg-gray-50 text-gray-600 dark:bg-gray-900 dark:text-gray-400", + label: "Offline", + }, +} + +function resolve(status: string) { + return RUN_STATUS_COLORS[status] ?? RUN_STATUS_COLORS.unknown +} + +// --------------------------------------------------------------------------- +// StatusDot — a small colored circle representing a run status. +// --------------------------------------------------------------------------- + +export function StatusDot({ status, className = "" }: { status: string; className?: string }) { + return +} + +// --------------------------------------------------------------------------- +// RunStatusIndicator — colored dot + label pill used on the sessions table. +// For attention-notice statuses it falls back to a Badge. +// --------------------------------------------------------------------------- + +export function RunStatusIndicator({ status }: { status: string }) { + const notice = runStatusNotice(status) + if (notice) return {notice.title} + const c = resolve(status) + return ( + + + {c.label} + + ) +} diff --git a/apps/server/src/components/sortable-table-head.tsx b/apps/server/src/components/sortable-table-head.tsx new file mode 100644 index 0000000..e8d2aaa --- /dev/null +++ b/apps/server/src/components/sortable-table-head.tsx @@ -0,0 +1,48 @@ +import { CaretDown, CaretUp, CaretUpDown } from "@phosphor-icons/react" +import { TableHead } from "@/components/ui/table" + +export type SortDir = "asc" | "desc" + +interface SortableTableHeadProps { + column: string + label: React.ReactNode + sortBy: string | null + sortDir: SortDir + onSort: (column: string) => void + className?: string +} + +export function SortableTableHead({ column, label, sortBy, sortDir, onSort, className = "" }: SortableTableHeadProps) { + const active = sortBy === column + return ( + onSort(column)} + > + + {label} + {active ? ( + sortDir === "asc" ? ( + + ) : ( + + ) + ) : ( + + )} + + + ) +} + +/** Toggle helper: clicking the active column flips direction; clicking a new column defaults to the given direction. */ +export function toggleSort( + current: { sortBy: string | null; sortDir: SortDir }, + column: string, + defaultDir: SortDir = "asc", +): { sortBy: string; sortDir: SortDir } { + if (current.sortBy === column) { + return { sortBy: column, sortDir: current.sortDir === "asc" ? "desc" : "asc" } + } + return { sortBy: column, sortDir: defaultDir } +} diff --git a/apps/server/src/routes/events/index.ts b/apps/server/src/routes/events/index.ts index 2fabca3..b3e1f9c 100644 --- a/apps/server/src/routes/events/index.ts +++ b/apps/server/src/routes/events/index.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, gte, like, lte, or, sql } from "drizzle-orm" +import { and, asc, 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" @@ -64,6 +64,18 @@ const router = new Hono() const entityKey = c.req.query("entityKey") const from = Number(c.req.query("from")) || undefined const to = Number(c.req.query("to")) || undefined + const sortByParam = c.req.query("sortBy") + const sortDirParam = c.req.query("sortDir") === "asc" ? "asc" : "desc" + + const SORTABLE_COLUMNS: Record = { + time: webhookEvents.createdAt, + status: webhookEvents.status, + repo: webhookEvents.repo, + sender: webhookEvents.sender, + event: webhookEvents.event, + } + const sortColumn = (sortByParam && SORTABLE_COLUMNS[sortByParam]) ?? webhookEvents.createdAt + const orderFn = sortDirParam === "asc" ? asc : desc const conditions = [] if (status) { @@ -116,7 +128,7 @@ const router = new Hono() }) .from(webhookEvents) .where(where) - .orderBy(desc(webhookEvents.createdAt)) + .orderBy(orderFn(sortColumn)) .limit(limit) .offset(offset), db.select({ count: sql`count(*)` }).from(webhookEvents).where(where), @@ -139,8 +151,9 @@ const router = new Hono() const now = new Date() const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000) + const twoDaysAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000) - const [totals, recentCount] = await Promise.all([ + const [totals, recentCount, previousCount] = await Promise.all([ db .select({ status: webhookEvents.status, @@ -152,6 +165,15 @@ const router = new Hono() .select({ count: sql`count(*)` }) .from(webhookEvents) .where(sql`${webhookEvents.createdAt} >= ${Math.floor(oneDayAgo.getTime() / 1000)}`), + db + .select({ count: sql`count(*)` }) + .from(webhookEvents) + .where( + and( + sql`${webhookEvents.createdAt} >= ${Math.floor(twoDaysAgo.getTime() / 1000)}`, + sql`${webhookEvents.createdAt} < ${Math.floor(oneDayAgo.getTime() / 1000)}`, + ), + ), ]) // A deployment can briefly run newer code before its D1 migration. The // heartbeat is observability only; never make the Events page unavailable @@ -191,6 +213,7 @@ const router = new Hono() stuck, skipped, last24h: recentCount[0]?.count ?? 0, + previous24h: previousCount[0]?.count ?? 0, maintenance: maintenance ? { cron: maintenance.cron,