diff --git a/docs/ai-coder/ai-gateway/audit.md b/docs/ai-coder/ai-gateway/audit.md index b43f8984413..743d44a35cc 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 0d162008cfd..2a8f2aa4030 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 1ff6c320435..747064f7089 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 9ba32bc54c3..efaf984c7f7 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 ff32f8230c3..80e8e7a1d6a 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 00000000000..b2a00ea81ec --- /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 00000000000..1d33e59719a --- /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 acb5a05d6bd..d7508dbd2a4 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} /> ); };