From 023f61626ddfd969ba338cd1a2465cf9d700ab27 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 19 Aug 2026 16:42:43 +0100 Subject: [PATCH] feat: default AI Gateway sessions list to a 24h time range (#28256) The AI Gateway sessions list page takes 5-13 seconds to load because `ListAIBridgeSessions` scans all `aibridge_interceptions` rows (~1M on dogfood) when no time filter is set. This PR defaults the list to the last 24 hours of sessions, reducing the scan by roughly two orders of magnitude without any backend or migration changes: the `started_after`/`started_before` filters already exist end-to-end in SQL, searchquery, and the API. The default range is held in component state (not the URL) and merged into every query payload including prefetches, so the unbounded query never runs on page load. A time range filter in the filter bar lets users override the window explicitly, which doubles as a forensic tool. Picking a range writes quoted RFC 3339 timestamps into the existing filter query. There are no presets and no unbounded "all time" mode, so the fast path is the only path. Existing "All sessions"/"My sessions" presets reset the filter query, which resets the time window to the default 24 hours; that is intentional. Also makes the five filter triggers a uniform width and left-aligns the new picker so the search input keeps room on wide viewports. Depends on #28255 (the DateTimeRangeFilter component) and #28254 (filterQuery serialization). Part of [AIGOV-580](https://linear.app/codercom/issue/AIGOV-580/ai-gateway-sessions-page-takes-5-10-seconds-to-load) --- _Generated by Coder Agents on behalf of @johnstcn._$ --- **Stack:** #28254 (filterQuery fix) \u2192 #28255 (component) \u2192 #28256 (sessions page) --- docs/ai-coder/ai-gateway/audit.md | 10 +- .../ListSessionsFilter.stories.tsx | 62 ++++++--- .../ListSessionsPage/ListSessionsFilter.tsx | 30 ++++- .../ListSessionsPage/ListSessionsPage.tsx | 27 +++- .../ListSessionsPageView.stories.tsx | 37 ++++-- .../ListSessionsPage/timeRange.test.ts | 119 ++++++++++++++++++ .../ListSessionsPage/timeRange.ts | 82 ++++++++++++ .../AIBridgePage/filters/ClientFilter.tsx | 4 +- .../AIBridgePage/filters/ModelFilter.tsx | 4 +- .../AIBridgePage/filters/ProviderFilter.tsx | 4 +- 10 files changed, 339 insertions(+), 40 deletions(-) create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts create mode 100644 site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts diff --git a/docs/ai-coder/ai-gateway/audit.md b/docs/ai-coder/ai-gateway/audit.md index b43f898441..743d44a35c 100644 --- a/docs/ai-coder/ai-gateway/audit.md +++ b/docs/ai-coder/ai-gateway/audit.md @@ -46,9 +46,13 @@ not just what was called. ### Sessions list -The sessions page (`http:///ai-gateway/sessions`) lists all sessions in -reverse-chronological order. Each row shows the last prompt, initiator, provider, -client, token usage, network requests, thread count, and timestamp. +The sessions page (`http:///ai-gateway/sessions`) lists sessions in +reverse-chronological order. By default it shows sessions with activity in the +last 24 hours. Use the time range filter in the filter bar to widen or narrow +the window, for example to see older sessions. + +Each row shows the last prompt, initiator, provider, client, token usage, +network requests, thread count, and timestamp. The **Network Requests** column reports the total and blocked [Agent Firewall](../agent-firewall/index.md) requests for the session. It shows diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx index 0d162008cf..2a8f2aa403 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; +import { fn } from "storybook/test"; +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; import { getDefaultFilterProps, MockMenu, @@ -8,21 +10,38 @@ import { ListSessionsFilter } from "./ListSessionsFilter"; type FilterProps = ComponentProps; -const defaultFilterProps = getDefaultFilterProps< - Pick ->({ - query: "", - values: { - username: undefined, - provider: undefined, - }, - menus: { - user: MockMenu, - provider: MockMenu, - client: MockMenu, - model: MockMenu, - }, -}); +type FilterAndMenus = Pick; + +const timeRange: TimeRange = { + startedAfter: new Date("2026-08-12T15:00:00Z"), + startedBefore: new Date("2026-08-13T15:00:00Z"), +}; + +const timeRangeProps: Pick< + FilterProps, + "timeRange" | "defaultTimeRange" | "onTimeRangeChange" +> = { + timeRange, + defaultTimeRange: timeRange, + onTimeRangeChange: fn(), +}; + +const defaultFilterProps = { + ...getDefaultFilterProps({ + query: "", + values: { + username: undefined, + provider: undefined, + }, + menus: { + user: MockMenu, + provider: MockMenu, + client: MockMenu, + model: MockMenu, + }, + }), + ...timeRangeProps, +}; const meta: Meta = { title: "pages/AIBridgePage/ListSessionsFilter", @@ -40,7 +59,7 @@ export const Default: Story = { export const WithQuery: Story = { args: { - ...getDefaultFilterProps>({ + ...getDefaultFilterProps({ query: "initiator:me", values: { username: "me", @@ -54,6 +73,17 @@ export const WithQuery: Story = { }, used: true, }), + ...timeRangeProps, + }, +}; + +export const ExplicitTimeRange: Story = { + args: { + ...defaultFilterProps, + timeRange: { + startedAfter: new Date("2026-08-01T09:30:00"), + startedBefore: new Date("2026-08-02T17:45:00"), + }, }, }; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx index 1ff6c32043..747064f708 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx @@ -1,4 +1,6 @@ import type { FC } from "react"; +import { DateTimeRangeFilter } from "#/components/DateTimeRangeFilter/DateTimeRangeFilter"; +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; import { Filter, MenuSkeleton, @@ -12,6 +14,10 @@ import { type ProviderFilterMenu, } from "../filters/ProviderFilter"; +// Narrower than the SelectFilter default so the search input keeps most +// of the row on wide viewports. +const FILTER_WIDTH = 150; + interface ListSessionsFilterProps { filter: ReturnType; error?: unknown; @@ -21,12 +27,18 @@ interface ListSessionsFilterProps { client: ClientFilterMenu; model: ModelFilterMenu; }; + timeRange: TimeRange; + defaultTimeRange: TimeRange; + onTimeRangeChange: (range: TimeRange) => void; } export const ListSessionsFilter: FC = ({ filter, error, menus, + timeRange, + defaultTimeRange, + onTimeRangeChange, }) => { return ( = ({ error={error} options={ <> - - - - + + + + + } /> diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx index 9ba32bc54c..efaf984c7f 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx @@ -1,7 +1,8 @@ import type { FC } from "react"; +import { useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { paginatedSessions } from "#/api/queries/aiBridge"; -import { useFilter } from "#/components/Filter/Filter"; +import { useFilter, useFilterParamsKey } from "#/components/Filter/Filter"; import { useUserFilterMenu } from "#/components/Filter/UserFilter"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { usePaginatedQuery } from "#/hooks/usePaginatedQuery"; @@ -13,6 +14,12 @@ import { useModelFilterMenu } from "../filters/ModelFilter"; import { useProviderFilterMenu } from "../filters/ProviderFilter"; import { getAIBridgePermissions } from "../getAIBridgePermissions"; import { ListSessionsPageView } from "./ListSessionsPageView"; +import { + defaultTimeRange, + parseTimeRange, + queryWithTimeRange, + withDefaultTimeRange, +} from "./timeRange"; const AISessionListPage: FC = () => { const { permissions } = useAuthenticated(); @@ -26,9 +33,21 @@ const AISessionListPage: FC = () => { const canViewSessions = isEntitled && hasPermission; + // The default time range lives in memory, not the URL, so a shared link + // resolves relative to the viewer's current time. It is fixed per mount + // so query cache keys stay stable. + const [defaultRange] = useState(() => defaultTimeRange(new Date())); + const [searchParams, setSearchParams] = useSearchParams(); const sessionsQuery = usePaginatedQuery({ ...paginatedSessions(searchParams), + // Merge the default range into every fetch (including prefetches) so + // the unfiltered sessions query never scans the entire table. + queryPayload: () => + withDefaultTimeRange( + searchParams.get(useFilterParamsKey) ?? "", + defaultRange, + ), enabled: canViewSessions, }); @@ -38,6 +57,8 @@ const AISessionListPage: FC = () => { onUpdate: sessionsQuery.goToFirstPage, }); + const explicitTimeRange = parseTimeRange(filter.values); + const timeRange = explicitTimeRange ?? defaultRange; const userMenu = useUserFilterMenu({ value: filter.values.initiator, onChange: (option) => @@ -97,6 +118,10 @@ const AISessionListPage: FC = () => { client: clientMenu, model: modelMenu, }, + timeRange, + defaultTimeRange: defaultRange, + onTimeRangeChange: (range) => + filter.update(queryWithTimeRange(filter.values, range)), }} /> diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx index ff32f8230c..80e8e7a1d6 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; import { fn } from "storybook/test"; +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; import { getDefaultFilterProps, MockMenu, @@ -14,19 +15,29 @@ import { ListSessionsPageView } from "./ListSessionsPageView"; type FilterProps = ComponentProps["filterProps"]; -const defaultFilterProps = getDefaultFilterProps({ - query: "owner:me", - values: { - username: undefined, - provider: undefined, - }, - menus: { - user: MockMenu, - provider: MockMenu, - client: MockMenu, - model: MockMenu, - }, -}); +const timeRange: TimeRange = { + startedAfter: new Date("2026-08-12T15:00:00Z"), + startedBefore: new Date("2026-08-13T15:00:00Z"), +}; + +const defaultFilterProps: FilterProps = { + ...getDefaultFilterProps({ + query: "owner:me", + values: { + username: undefined, + provider: undefined, + }, + menus: { + user: MockMenu, + provider: MockMenu, + client: MockMenu, + model: MockMenu, + }, + }), + timeRange, + defaultTimeRange: timeRange, + onTimeRangeChange: fn(), +}; const meta: Meta = { title: "pages/AIBridgePage/ListSessionsPageView", diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts new file mode 100644 index 0000000000..b2a00ea81e --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; +import { + defaultTimeRange, + parseTimeRange, + queryWithTimeRange, + toRFC3339, + withDefaultTimeRange, +} from "./timeRange"; + +const now = new Date(2026, 7, 13, 15, 0, 0); + +describe("toRFC3339", () => { + it("serializes in UTC with second precision", () => { + expect(toRFC3339(new Date(Date.UTC(2026, 7, 13, 10, 0, 0, 500)))).toBe( + "2026-08-13T10:00:00Z", + ); + }); +}); + +describe("defaultTimeRange", () => { + it("spans the 24 hours ending at now", () => { + const range = defaultTimeRange(now); + expect(range.startedBefore).toEqual(now); + expect(range.startedAfter).toEqual( + new Date(now.getTime() - 24 * 60 * 60 * 1000), + ); + }); +}); + +describe("withDefaultTimeRange", () => { + const range: TimeRange = { + startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), + startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), + }; + + it("appends the default range to an empty query", () => { + expect(withDefaultTimeRange("", range)).toBe( + 'started_after:"2026-08-12T15:00:00Z" started_before:"2026-08-13T15:00:00Z"', + ); + }); + + it("appends the default range alongside other filters", () => { + expect(withDefaultTimeRange("initiator:me", range)).toBe( + 'initiator:me started_after:"2026-08-12T15:00:00Z" started_before:"2026-08-13T15:00:00Z"', + ); + }); + + it("leaves a query with an explicit time range untouched", () => { + const afterOnly = 'started_after:"2026-08-01T00:00:00Z"'; + expect(withDefaultTimeRange(afterOnly, range)).toBe(afterOnly); + + const beforeOnly = 'started_before:"2026-08-01T00:00:00Z"'; + expect(withDefaultTimeRange(beforeOnly, range)).toBe(beforeOnly); + }); + + it("does not mistake a quoted value containing the key for a bound", () => { + // A quoted value that mentions started_after is not a time bound. + const query = 'session_id:"started_after:2026-08-01"'; + expect(withDefaultTimeRange(query, range)).toBe( + 'session_id:"started_after:2026-08-01" started_after:"2026-08-12T15:00:00Z" started_before:"2026-08-13T15:00:00Z"', + ); + }); +}); + +describe("queryWithTimeRange", () => { + const range: TimeRange = { + startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), + startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), + }; + + it("preserves other filters and replaces the time range", () => { + expect( + queryWithTimeRange( + { + initiator: "me", + started_after: "2026-01-01T00:00:00Z", + started_before: "2026-01-02T00:00:00Z", + }, + range, + ), + ).toBe( + 'initiator:me started_after:"2026-08-12T15:00:00Z" started_before:"2026-08-13T15:00:00Z"', + ); + }); +}); + +describe("parseTimeRange", () => { + it("parses an explicit range", () => { + expect( + parseTimeRange({ + started_after: "2026-08-12T15:00:00Z", + started_before: "2026-08-13T15:00:00Z", + }), + ).toEqual({ + startedAfter: new Date(Date.UTC(2026, 7, 12, 15, 0, 0)), + startedBefore: new Date(Date.UTC(2026, 7, 13, 15, 0, 0)), + }); + }); + + it("returns null when either bound is missing", () => { + expect(parseTimeRange({})).toBeNull(); + expect( + parseTimeRange({ started_after: "2026-08-12T15:00:00Z" }), + ).toBeNull(); + expect( + parseTimeRange({ started_before: "2026-08-13T15:00:00Z" }), + ).toBeNull(); + }); + + it("returns null for malformed bounds", () => { + expect( + parseTimeRange({ + started_after: "bogus", + started_before: "2026-08-13T15:00:00Z", + }), + ).toBeNull(); + }); +}); diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts new file mode 100644 index 0000000000..1d33e59719 --- /dev/null +++ b/site/src/pages/AIBridgePage/ListSessionsPage/timeRange.ts @@ -0,0 +1,82 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import type { TimeRange } from "#/components/DateTimeRangeFilter/timeRange"; +import { + parseFilterQuery, + stringifyFilter, +} from "#/components/Filter/filterQuery"; + +dayjs.extend(utc); + +/** Serializes a Date as RFC 3339 in UTC with second precision. */ +export const toRFC3339 = (date: Date): string => { + return dayjs(date).utc().format("YYYY-MM-DDTHH:mm:ss[Z]"); +}; + +/** The default sessions window: the 24 hours ending at now. */ +export const defaultTimeRange = (now: Date): TimeRange => ({ + startedAfter: new Date(now.getTime() - 24 * 60 * 60 * 1000), + startedBefore: now, +}); + +/** + * Appends the default time range to a filter query unless the query already + * sets started_after or started_before. A deliberately one-sided query is + * left alone. The default is kept in memory instead of the URL so shared + * links resolve relative to the viewer's current time. + */ +export const withDefaultTimeRange = ( + query: string, + range: TimeRange, +): string => { + const values = parseFilterQuery(query); + if ( + values.started_after !== undefined || + values.started_before !== undefined + ) { + return query; + } + const suffix = stringifyFilter({ + ...values, + started_after: toRFC3339(range.startedAfter), + started_before: toRFC3339(range.startedBefore), + }); + return suffix; +}; + +/** + * Builds a filter query string that replaces any existing time range in + * values with the given range while preserving all other filters. + * stringifyFilter quotes the RFC 3339 values because the backend query + * parser treats unquoted colons as key/value separators. + */ +export const queryWithTimeRange = ( + values: Record, + range: TimeRange, +): string => { + return stringifyFilter({ + ...values, + started_after: toRFC3339(range.startedAfter), + started_before: toRFC3339(range.startedBefore), + }); +}; + +/** Extracts an explicit time range from filter values, or null if absent. */ +export const parseTimeRange = ( + values: Record, +): TimeRange | null => { + const after = values.started_after; + const before = values.started_before; + if (!after || !before) { + return null; + } + const startedAfter = new Date(after); + const startedBefore = new Date(before); + if ( + Number.isNaN(startedAfter.getTime()) || + Number.isNaN(startedBefore.getTime()) + ) { + return null; + } + return { startedAfter, startedBefore }; +}; diff --git a/site/src/pages/AIBridgePage/filters/ClientFilter.tsx b/site/src/pages/AIBridgePage/filters/ClientFilter.tsx index acb5a05d6b..d7508dbd2a 100644 --- a/site/src/pages/AIBridgePage/filters/ClientFilter.tsx +++ b/site/src/pages/AIBridgePage/filters/ClientFilter.tsx @@ -56,9 +56,10 @@ export type ClientFilterMenu = ReturnType; interface ClientFilterProps { menu: ClientFilterMenu; + width?: number; } -export const ClientFilter: React.FC = ({ menu }) => { +export const ClientFilter: React.FC = ({ menu, width }) => { return ( = ({ menu }) => { options={menu.searchOptions} onSelect={(option) => menu.selectOption(option)} selectedOption={menu.selectedOption ?? undefined} + width={width} selectFilterSearch={ ; interface ModelFilterProps { menu: ModelFilterMenu; + width?: number; } -export const ModelFilter: FC = ({ menu }) => { +export const ModelFilter: FC = ({ menu, width }) => { return ( = ({ menu }) => { options={menu.searchOptions} onSelect={(option) => menu.selectOption(option)} selectedOption={menu.selectedOption ?? undefined} + width={width} selectFilterSearch={ ; interface ProviderFilterProps { menu: ProviderFilterMenu; + width?: number; } -export const ProviderFilter: FC = ({ menu }) => { +export const ProviderFilter: FC = ({ menu, width }) => { return ( = ({ menu }) => { options={menu.searchOptions} onSelect={(option) => menu.selectOption(option)} selectedOption={menu.selectedOption ?? undefined} + width={width} /> ); };