Skip to content

Commit c64b860

Browse files
authored
feat: add stickysession selection box to select multiple sessions too be deleted (#286)
1 parent 502db37 commit c64b860

13 files changed

Lines changed: 284 additions & 65 deletions

File tree

app/modules/proxy/sticky_repository.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass
55
from datetime import datetime, timedelta
66

7-
from sqlalchemy import delete, select
7+
from sqlalchemy import and_, delete, or_, select
88
from sqlalchemy.dialects.postgresql import insert as pg_insert
99
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
1010
from sqlalchemy.ext.asyncio import AsyncSession
@@ -74,6 +74,18 @@ async def delete(self, key: str, *, kind: StickySessionKind) -> bool:
7474
await self._session.commit()
7575
return result.scalar_one_or_none() is not None
7676

77+
async def delete_entries(self, entries: Sequence[tuple[str, StickySessionKind]]) -> int:
78+
targets = {(key, kind) for key, kind in entries if key}
79+
if not targets:
80+
return 0
81+
statement = delete(StickySession).where(
82+
or_(*(and_(StickySession.key == key, StickySession.kind == kind) for key, kind in targets))
83+
)
84+
result = await self._session.execute(statement.returning(StickySession.key))
85+
deleted = len(result.scalars().all())
86+
await self._session.commit()
87+
return deleted
88+
7789
async def list_entries(
7890
self,
7991
*,

app/modules/sticky_sessions/api.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from app.modules.sticky_sessions.schemas import (
1010
StickySessionDeleteResponse,
1111
StickySessionEntryResponse,
12+
StickySessionsDeleteRequest,
13+
StickySessionsDeleteResponse,
1214
StickySessionsListResponse,
1315
StickySessionsPurgeRequest,
1416
StickySessionsPurgeResponse,
@@ -58,6 +60,15 @@ async def purge_sticky_sessions(
5860
return StickySessionsPurgeResponse(deleted_count=deleted_count)
5961

6062

63+
@router.post("/delete", response_model=StickySessionsDeleteResponse)
64+
async def delete_sticky_sessions(
65+
payload: StickySessionsDeleteRequest,
66+
context: StickySessionsContext = Depends(get_sticky_sessions_context),
67+
) -> StickySessionsDeleteResponse:
68+
deleted_count = await context.service.delete_entries([(entry.key, entry.kind) for entry in payload.sessions])
69+
return StickySessionsDeleteResponse(deleted_count=deleted_count)
70+
71+
6172
@router.delete("/{kind}/{key:path}", response_model=StickySessionDeleteResponse)
6273
async def delete_sticky_session(
6374
kind: StickySessionKind,

app/modules/sticky_sessions/schemas.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,23 @@ class StickySessionsListResponse(DashboardModel):
2626
has_more: bool = False
2727

2828

29+
class StickySessionIdentifier(DashboardModel):
30+
key: str = Field(min_length=1)
31+
kind: StickySessionKind
32+
33+
2934
class StickySessionDeleteResponse(DashboardModel):
3035
status: str
3136

3237

38+
class StickySessionsDeleteRequest(DashboardModel):
39+
sessions: list[StickySessionIdentifier] = Field(min_length=1, max_length=500)
40+
41+
42+
class StickySessionsDeleteResponse(DashboardModel):
43+
deleted_count: int
44+
45+
3346
class StickySessionsPurgeRequest(DashboardModel):
3447
stale_only: Literal[True] = True
3548

app/modules/sticky_sessions/service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections.abc import Sequence
34
from dataclasses import dataclass
45
from datetime import datetime, timedelta
56

@@ -78,6 +79,9 @@ async def list_entries(
7879
async def delete_entry(self, key: str, *, kind: StickySessionKind) -> bool:
7980
return await self._repository.delete(key, kind=kind)
8081

82+
async def delete_entries(self, entries: Sequence[tuple[str, StickySessionKind]]) -> int:
83+
return await self._repository.delete_entries(entries)
84+
8185
async def purge_entries(self) -> int:
8286
settings = await self._settings_repository.get_or_create()
8387
cutoff = utcnow() - timedelta(seconds=settings.openai_cache_affinity_max_age_seconds)

frontend/src/features/sticky-sessions/api.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { del, get, post } from "@/lib/api-client";
22

33
import {
4-
StickySessionDeleteResponseSchema,
54
StickySessionIdentifierSchema,
5+
StickySessionsDeleteRequestSchema,
6+
StickySessionsDeleteResponseSchema,
67
StickySessionsListParamsSchema,
78
StickySessionsListResponseSchema,
89
StickySessionsPurgeRequestSchema,
@@ -23,10 +24,14 @@ export function listStickySessions(params: unknown) {
2324

2425
export function deleteStickySession(payload: unknown) {
2526
const validated = StickySessionIdentifierSchema.parse(payload);
26-
return del(
27-
`${STICKY_SESSIONS_PATH}/${validated.kind}/${encodeURIComponent(validated.key)}`,
28-
StickySessionDeleteResponseSchema,
29-
);
27+
return del(`${STICKY_SESSIONS_PATH}/${validated.kind}/${encodeURIComponent(validated.key)}`);
28+
}
29+
30+
export function deleteStickySessions(payload: unknown) {
31+
const validated = StickySessionsDeleteRequestSchema.parse(payload);
32+
return post(`${STICKY_SESSIONS_PATH}/delete`, StickySessionsDeleteResponseSchema, {
33+
body: validated,
34+
});
3035
}
3136

3237
export function purgeStickySessions(payload: unknown) {

frontend/src/features/sticky-sessions/components/sticky-sessions-section.test.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe("StickySessionsSection", () => {
1616
vi.clearAllMocks();
1717
});
1818

19-
it("renders rows and supports purge and remove actions", async () => {
19+
it("renders rows and supports selection, purge, and remove actions", async () => {
2020
const user = userEvent.setup();
2121
const deleteMutation = {
2222
mutateAsync: vi.fn().mockResolvedValue(undefined),
@@ -83,6 +83,26 @@ describe("StickySessionsSection", () => {
8383
expect(screen.getByText("1")).toBeInTheDocument();
8484
expect(screen.getByText("1–2 of 2")).toBeInTheDocument();
8585

86+
await user.click(screen.getByRole("checkbox", { name: "Select all visible sticky sessions" }));
87+
expect(screen.getByText("Selected")).toBeInTheDocument();
88+
expect(screen.getByRole("button", { name: "Remove selected" })).toBeEnabled();
89+
90+
await user.click(screen.getByRole("button", { name: "Remove selected" }));
91+
await user.click(screen.getByRole("button", { name: "Remove selected" }));
92+
93+
await waitFor(() => {
94+
expect(deleteMutation.mutateAsync).toHaveBeenNthCalledWith(1, [
95+
{
96+
key: "session-1",
97+
kind: "prompt_cache",
98+
},
99+
{
100+
key: "session-2",
101+
kind: "codex_session",
102+
},
103+
]);
104+
});
105+
86106
await user.click(screen.getByRole("button", { name: "Purge stale" }));
87107
await user.click(screen.getByRole("button", { name: "Purge" }));
88108

@@ -94,10 +114,12 @@ describe("StickySessionsSection", () => {
94114
await user.click(screen.getByRole("button", { name: "Remove" }));
95115

96116
await waitFor(() => {
97-
expect(deleteMutation.mutateAsync).toHaveBeenCalledWith({
98-
key: "session-1",
99-
kind: "prompt_cache",
100-
});
117+
expect(deleteMutation.mutateAsync).toHaveBeenNthCalledWith(2, [
118+
{
119+
key: "session-1",
120+
kind: "prompt_cache",
121+
},
122+
]);
101123
});
102124
});
103125

frontend/src/features/sticky-sessions/components/sticky-sessions-section.tsx

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useMemo } from "react";
1+
import { useMemo, useState } from "react";
22
import { Pin } from "lucide-react";
33

44
import { AlertMessage } from "@/components/alert-message";
55
import { ConfirmDialog } from "@/components/confirm-dialog";
66
import { EmptyState } from "@/components/empty-state";
77
import { Badge } from "@/components/ui/badge";
88
import { Button } from "@/components/ui/button";
9+
import { Checkbox } from "@/components/ui/checkbox";
910
import { SpinnerBlock } from "@/components/ui/spinner";
1011
import {
1112
Table,
@@ -17,7 +18,7 @@ import {
1718
} from "@/components/ui/table";
1819
import { PaginationControls } from "@/features/dashboard/components/filters/pagination-controls";
1920
import { useStickySessions } from "@/features/sticky-sessions/hooks/use-sticky-sessions";
20-
import type { StickySessionIdentifier, StickySessionKind } from "@/features/sticky-sessions/schemas";
21+
import type { StickySessionEntry, StickySessionIdentifier, StickySessionKind } from "@/features/sticky-sessions/schemas";
2122
import { useDialogState } from "@/hooks/use-dialog-state";
2223
import { getErrorMessageOrNull } from "@/utils/errors";
2324
import { formatTimeLong } from "@/utils/formatters";
@@ -33,10 +34,18 @@ function kindLabel(kind: StickySessionKind): string {
3334
}
3435
}
3536

37+
function stickySessionRowId(entry: StickySessionIdentifier): string {
38+
return `${entry.kind}:${entry.key}`;
39+
}
40+
41+
const EMPTY_STICKY_SESSION_ENTRIES: StickySessionEntry[] = [];
42+
3643
export function StickySessionsSection() {
3744
const { params, setLimit, setOffset, stickySessionsQuery, deleteMutation, purgeMutation } = useStickySessions();
3845
const deleteDialog = useDialogState<StickySessionIdentifier>();
46+
const deleteSelectedDialog = useDialogState<StickySessionIdentifier[]>();
3947
const purgeDialog = useDialogState();
48+
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
4049

4150
const mutationError = useMemo(
4251
() =>
@@ -46,13 +55,40 @@ export function StickySessionsSection() {
4655
[stickySessionsQuery.error, deleteMutation.error, purgeMutation.error],
4756
);
4857

49-
const entries = stickySessionsQuery.data?.entries ?? [];
58+
const entries = stickySessionsQuery.data?.entries ?? EMPTY_STICKY_SESSION_ENTRIES;
5059
const staleCount = stickySessionsQuery.data?.stalePromptCacheCount ?? 0;
5160
const total = stickySessionsQuery.data?.total ?? 0;
5261
const hasMore = stickySessionsQuery.data?.hasMore ?? false;
5362
const busy = deleteMutation.isPending || purgeMutation.isPending;
5463
const hasEntries = entries.length > 0;
5564
const hasAnyRows = total > 0;
65+
const selectedRowIdSet = useMemo(() => new Set(selectedRowIds), [selectedRowIds]);
66+
const selectedEntries = useMemo(
67+
() =>
68+
entries
69+
.filter((entry) => selectedRowIdSet.has(stickySessionRowId(entry)))
70+
.map(({ key, kind }) => ({ key, kind })),
71+
[entries, selectedRowIdSet],
72+
);
73+
const selectedCount = selectedEntries.length;
74+
const allVisibleSelected = hasEntries && selectedCount === entries.length;
75+
const someVisibleSelected = selectedCount > 0 && !allVisibleSelected;
76+
const selectedDeleteTargets = deleteSelectedDialog.data ?? [];
77+
const selectedDeleteCount = selectedDeleteTargets.length;
78+
79+
const setSelected = (target: StickySessionIdentifier, checked: boolean) => {
80+
const rowId = stickySessionRowId(target);
81+
setSelectedRowIds((current) => {
82+
if (checked) {
83+
return current.includes(rowId) ? current : [...current, rowId];
84+
}
85+
return current.filter((value) => value !== rowId);
86+
});
87+
};
88+
89+
const setAllVisibleSelected = (checked: boolean) => {
90+
setSelectedRowIds(checked ? entries.map((entry) => stickySessionRowId(entry)) : []);
91+
};
5692

5793
return (
5894
<section className="space-y-3 rounded-xl border bg-card p-5">
@@ -80,17 +116,35 @@ export function StickySessionsSection() {
80116
<span className="text-xs text-muted-foreground">Stale prompt-cache</span>
81117
<span className="text-sm font-medium tabular-nums">{staleCount}</span>
82118
</div>
119+
{selectedCount > 0 ? (
120+
<div className="flex items-center gap-1.5">
121+
<span className="text-xs text-muted-foreground">Selected</span>
122+
<span className="text-sm font-medium tabular-nums">{selectedCount}</span>
123+
</div>
124+
) : null}
125+
</div>
126+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
127+
<Button
128+
type="button"
129+
size="sm"
130+
variant="destructive"
131+
className="h-8 text-xs"
132+
disabled={busy || selectedCount === 0}
133+
onClick={() => deleteSelectedDialog.show(selectedEntries)}
134+
>
135+
Remove selected
136+
</Button>
137+
<Button
138+
type="button"
139+
size="sm"
140+
variant="outline"
141+
className="h-8 text-xs"
142+
disabled={busy || staleCount === 0}
143+
onClick={() => purgeDialog.show()}
144+
>
145+
Purge stale
146+
</Button>
83147
</div>
84-
<Button
85-
type="button"
86-
size="sm"
87-
variant="outline"
88-
className="h-8 text-xs"
89-
disabled={busy || staleCount === 0}
90-
onClick={() => purgeDialog.show()}
91-
>
92-
Purge stale
93-
</Button>
94148
</div>
95149

96150
{stickySessionsQuery.isLoading && !stickySessionsQuery.data ? (
@@ -110,7 +164,15 @@ export function StickySessionsSection() {
110164
<Table className="table-fixed">
111165
<TableHeader>
112166
<TableRow>
113-
<TableHead className="w-[30%] min-w-[14rem] pl-4 text-[11px] uppercase tracking-wider text-muted-foreground/80">
167+
<TableHead className="w-[5%] min-w-[3rem] pl-4 text-[11px] uppercase tracking-wider text-muted-foreground/80">
168+
<Checkbox
169+
aria-label="Select all visible sticky sessions"
170+
checked={allVisibleSelected ? true : someVisibleSelected ? "indeterminate" : false}
171+
disabled={busy || !hasEntries}
172+
onCheckedChange={(checked) => setAllVisibleSelected(checked === true)}
173+
/>
174+
</TableHead>
175+
<TableHead className="w-[25%] min-w-[14rem] text-[11px] uppercase tracking-wider text-muted-foreground/80">
114176
Key
115177
</TableHead>
116178
<TableHead className="w-[14%] min-w-[8rem] text-[11px] uppercase tracking-wider text-muted-foreground/80">
@@ -134,9 +196,18 @@ export function StickySessionsSection() {
134196
{entries.map((entry) => {
135197
const updated = formatTimeLong(entry.updatedAt);
136198
const expires = entry.expiresAt ? formatTimeLong(entry.expiresAt) : null;
199+
const selected = selectedRowIdSet.has(stickySessionRowId(entry));
137200
return (
138-
<TableRow key={`${entry.kind}:${entry.key}`}>
139-
<TableCell className="max-w-[18rem] truncate pl-4 font-mono text-xs" title={entry.key}>
201+
<TableRow key={`${entry.kind}:${entry.key}`} data-state={selected ? "selected" : undefined}>
202+
<TableCell className="pl-4">
203+
<Checkbox
204+
aria-label={`Select sticky session ${entry.key}`}
205+
checked={selected}
206+
disabled={busy}
207+
onCheckedChange={(checked) => setSelected(entry, checked === true)}
208+
/>
209+
</TableCell>
210+
<TableCell className="max-w-[18rem] truncate font-mono text-xs" title={entry.key}>
140211
{entry.key}
141212
</TableCell>
142213
<TableCell>
@@ -207,12 +278,33 @@ export function StickySessionsSection() {
207278
if (!deleteDialog.data) {
208279
return;
209280
}
210-
void deleteMutation.mutateAsync(deleteDialog.data).finally(() => {
281+
void deleteMutation.mutateAsync([deleteDialog.data]).finally(() => {
211282
deleteDialog.hide();
212283
});
213284
}}
214285
/>
215286

287+
<ConfirmDialog
288+
open={deleteSelectedDialog.open}
289+
title="Remove selected sticky sessions"
290+
description={
291+
selectedDeleteCount === 1
292+
? "The selected sticky session will stop pinning future requests."
293+
: `${selectedDeleteCount} selected sticky sessions will stop pinning future requests.`
294+
}
295+
confirmLabel="Remove selected"
296+
onOpenChange={deleteSelectedDialog.onOpenChange}
297+
onConfirm={() => {
298+
if (selectedDeleteTargets.length === 0) {
299+
return;
300+
}
301+
void deleteMutation.mutateAsync(selectedDeleteTargets).finally(() => {
302+
setSelectedRowIds([]);
303+
deleteSelectedDialog.hide();
304+
});
305+
}}
306+
/>
307+
216308
<ConfirmDialog
217309
open={purgeDialog.open}
218310
title="Purge stale prompt-cache mappings"

0 commit comments

Comments
 (0)