feat(monitoring): paginate sessions and messages (#2489)

* feat(monitoring): paginate sessions and messages

* fix(monitoring): align detail pages with local dates

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-09-01 15:14:29 +08:00
committed by GitHub
parent 7aab0cee07
commit bf8d418ad4
7 changed files with 455 additions and 23 deletions
@@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids = quart.request.args.getlist('pipelineId') pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime') start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime') end_time_str = quart.request.args.get('endTime')
user_query = quart.request.args.get('userQuery')
is_active_str = quart.request.args.get('isActive') is_active_str = quart.request.args.get('isActive')
limit = int(quart.request.args.get('limit', 100)) limit = int(quart.request.args.get('limit', 100))
offset = int(quart.request.args.get('offset', 0)) offset = int(quart.request.args.get('offset', 0))
@@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids=pipeline_ids if pipeline_ids else None, pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
user_query=user_query,
is_active=is_active, is_active=is_active,
limit=limit, limit=limit,
offset=offset, offset=offset,
@@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup):
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW) @self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str: async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""Get detailed analysis for a specific session""" """Get detailed analysis for a specific session"""
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id) start_time = parse_iso_datetime(quart.request.args.get('startTime'))
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
analysis = await self.ap.monitoring_service.get_session_analysis(
request_context,
session_id,
start_time=start_time,
end_time=end_time,
)
# Always return success with the analysis data # Always return success with the analysis data
# The frontend will handle the 'found: false' case # The frontend will handle the 'found: false' case
+20 -4
View File
@@ -1257,6 +1257,7 @@ class MonitoringService:
pipeline_ids: list[str] | None = None, pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None, start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None,
user_query: str | None = None,
is_active: bool | None = None, is_active: bool | None = None,
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
@@ -1274,6 +1275,14 @@ class MonitoringService:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time: if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
if user_query and user_query.strip():
user_pattern = f'%{user_query.strip()}%'
conditions.append(
sqlalchemy.or_(
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
)
)
if is_active is not None: if is_active is not None:
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active) conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
@@ -1365,6 +1374,8 @@ class MonitoringService:
self, self,
context: TenantContext, context: TenantContext,
session_id: str, session_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict: ) -> dict:
"""Get bounded session details with full statistics computed in SQL.""" """Get bounded session details with full statistics computed in SQL."""
workspace_uuid = require_workspace_uuid(context) workspace_uuid = require_workspace_uuid(context)
@@ -1478,12 +1489,17 @@ class MonitoringService:
) )
) )
tool_stats = tool_stats_result.one() tool_stats = tool_stats_result.one()
tool_conditions = [
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
]
if start_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
if end_time is not None:
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
tool_query = ( tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall) sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
.where( .where(*tool_conditions)
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc()) .order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
.limit(detail_limit + 1) .limit(detail_limit + 1)
) )
+24 -2
View File
@@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint:
assert response.status_code == 200 assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
response = await quart_test_client.get(
'/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40',
headers={'Authorization': 'Bearer test_token'},
)
assert response.status_code == 200
kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs
assert kwargs['bot_ids'] == ['bot-1']
assert kwargs['user_query'] == 'alice'
assert kwargs['limit'] == 20
assert kwargs['offset'] == 40
@pytest.mark.usefixtures('mock_circular_import_chain') @pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringErrorsEndpoint: class TestMonitoringErrorsEndpoint:
@@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints:
"""Tests for detail endpoints.""" """Tests for detail endpoints."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_session_analysis(self, quart_test_client): async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app):
"""GET /api/v1/monitoring/sessions/{id}/analysis.""" """GET /api/v1/monitoring/sessions/{id}/analysis."""
response = await quart_test_client.get( response = await quart_test_client.get(
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'} '/api/v1/monitoring/sessions/sess-1/analysis'
'?startTime=2026-08-31T16%3A00%3A00.000Z'
'&endTime=2026-09-01T15%3A59%3A59.999Z',
headers={'Authorization': 'Bearer test_token'},
) )
assert response.status_code == 200 assert response.status_code == 200
kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs
assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00'
assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_message_details(self, quart_test_client): async def test_get_message_details(self, quart_test_client):
@@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
assert (await service.get_message_details(context_a, message_b))['found'] is False assert (await service.get_message_details(context_a, message_b))['found'] is False
async def test_session_search_matches_user_id_or_name_within_workspace(service):
context_a = _context(WORKSPACE_A)
context_b = _context(WORKSPACE_B)
fixtures = [
(context_a, 'session-id-match', 'customer-42', 'Alice'),
(context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'),
(context_a, 'session-no-match', 'customer-7', 'Bob'),
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
]
for context, session_id, user_id, user_name in fixtures:
await service.record_session_start(
context,
session_id=session_id,
bot_id='same-bot',
bot_name='Same Bot',
pipeline_id='same-pipeline',
pipeline_name='Same Pipeline',
user_id=user_id,
user_name=user_name,
)
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
assert id_total == 1
assert [session['session_id'] for session in by_id] == ['session-id-match']
assert name_total == 2
assert {session['session_id'] for session in by_name} == {
'session-id-match',
'session-name-match',
}
async def test_tool_call_inherits_context_from_connection_message_row(service): async def test_tool_call_inherits_context_from_connection_message_row(service):
context = _context(WORKSPACE_A) context = _context(WORKSPACE_A)
message_id = await _record_message(service, context, 'tool context') message_id = await _record_message(service, context, 'tool context')
@@ -17,6 +17,7 @@ import {
Copy, Copy,
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft,
ChevronRight, ChevronRight,
Workflow, Workflow,
ThumbsUp, ThumbsUp,
@@ -117,16 +118,43 @@ interface BotSessionMonitorProps {
botId: string; botId: string;
} }
const SESSION_PAGE_SIZE = 20;
const MESSAGE_PAGE_SIZE = 50;
const localDateBoundaryToISOString = (
dateValue: string,
endOfDay: boolean,
): string => {
const [year, month, day] = dateValue.split('-').map(Number);
return new Date(
year,
month - 1,
day,
endOfDay ? 23 : 0,
endOfDay ? 59 : 0,
endOfDay ? 59 : 0,
endOfDay ? 999 : 0,
).toISOString();
};
const BotSessionMonitor = forwardRef< const BotSessionMonitor = forwardRef<
BotSessionMonitorHandle, BotSessionMonitorHandle,
BotSessionMonitorProps BotSessionMonitorProps
>(function BotSessionMonitor({ botId }, ref) { >(function BotSessionMonitor({ botId }, ref) {
const { t } = useTranslation(); const { t } = useTranslation();
const [sessions, setSessions] = useState<SessionInfo[]>([]); const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [sessionTotal, setSessionTotal] = useState(0);
const [sessionPage, setSessionPage] = useState(0);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [userQuery, setUserQuery] = useState('');
const [appliedUserQuery, setAppliedUserQuery] = useState('');
const [selectedSessionId, setSelectedSessionId] = useState<string | null>( const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null, null,
); );
const [messages, setMessages] = useState<SessionMessage[]>([]); const [messages, setMessages] = useState<SessionMessage[]>([]);
const [messageTotal, setMessageTotal] = useState(0);
const [messagePage, setMessagePage] = useState(0);
const [loadingSessions, setLoadingSessions] = useState(false); const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false); const [copiedUserId, setCopiedUserId] = useState(false);
@@ -138,6 +166,8 @@ const BotSessionMonitor = forwardRef<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const sessionRequestIdRef = useRef(0);
const messageRequestIdRef = useRef(0);
const { admins, reload: reloadAdmins } = useBotAdmins(botId); const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false); const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null); const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
@@ -204,16 +234,33 @@ const BotSessionMonitor = forwardRef<
}; };
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
const requestId = ++sessionRequestIdRef.current;
setLoadingSessions(true); setLoadingSessions(true);
try { try {
const response = await httpClient.getBotSessions(botId); const response = await httpClient.getBotSessions(botId, {
limit: SESSION_PAGE_SIZE,
offset: sessionPage * SESSION_PAGE_SIZE,
startTime: startDate
? localDateBoundaryToISOString(startDate, false)
: undefined,
endTime: endDate
? localDateBoundaryToISOString(endDate, true)
: undefined,
userQuery: appliedUserQuery || undefined,
});
if (requestId !== sessionRequestIdRef.current) return;
setSessions(response.sessions ?? []); setSessions(response.sessions ?? []);
setSessionTotal(response.total ?? 0);
} catch (error) { } catch (error) {
console.error('Failed to load sessions:', error); if (requestId === sessionRequestIdRef.current) {
console.error('Failed to load sessions:', error);
}
} finally { } finally {
setLoadingSessions(false); if (requestId === sessionRequestIdRef.current) {
setLoadingSessions(false);
}
} }
}, [botId]); }, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
useImperativeHandle( useImperativeHandle(
ref, ref,
@@ -224,25 +271,39 @@ const BotSessionMonitor = forwardRef<
); );
const loadMessages = useCallback( const loadMessages = useCallback(
async (sessionId: string) => { async (sessionId: string, page: number) => {
const requestId = ++messageRequestIdRef.current;
setLoadingMessages(true); setLoadingMessages(true);
setExpandedToolCallIds({}); setExpandedToolCallIds({});
try { try {
const messagesRes = await httpClient.getSessionMessages(sessionId); const messagesRes = await httpClient.getSessionMessages(
sessionId,
MESSAGE_PAGE_SIZE,
page * MESSAGE_PAGE_SIZE,
);
if (requestId !== messageRequestIdRef.current) return;
const sorted = (messagesRes.messages ?? []).sort( const sorted = (messagesRes.messages ?? []).sort(
(a, b) => (a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
); );
setMessages(sorted); setMessages(sorted);
setMessageTotal(messagesRes.total ?? 0);
try { try {
const analysisParams = new URLSearchParams();
if (sorted.length > 0) {
analysisParams.set('startTime', sorted[0].timestamp);
analysisParams.set('endTime', sorted[sorted.length - 1].timestamp);
}
const analysisRes = await httpClient.get<{ const analysisRes = await httpClient.get<{
tool_calls?: SessionToolCall[]; tool_calls?: SessionToolCall[];
}>( }>(
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`, `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`,
); );
if (requestId !== messageRequestIdRef.current) return;
setToolCalls(analysisRes?.tool_calls ?? []); setToolCalls(analysisRes?.tool_calls ?? []);
} catch (analysisError) { } catch (analysisError) {
if (requestId !== messageRequestIdRef.current) return;
console.error('Failed to load session tool calls:', analysisError); console.error('Failed to load session tool calls:', analysisError);
setToolCalls([]); setToolCalls([]);
} }
@@ -259,6 +320,7 @@ const BotSessionMonitor = forwardRef<
}>( }>(
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`, `/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
); );
if (requestId !== messageRequestIdRef.current) return;
const map: Record<string, SessionFeedback> = {}; const map: Record<string, SessionFeedback> = {};
if (feedbackRes?.feedback) { if (feedbackRes?.feedback) {
@@ -273,9 +335,13 @@ const BotSessionMonitor = forwardRef<
setFeedbackMap({}); setFeedbackMap({});
} }
} catch (error) { } catch (error) {
console.error('Failed to load session messages:', error); if (requestId === messageRequestIdRef.current) {
console.error('Failed to load session messages:', error);
}
} finally { } finally {
setLoadingMessages(false); if (requestId === messageRequestIdRef.current) {
setLoadingMessages(false);
}
} }
}, },
[botId], [botId],
@@ -285,16 +351,24 @@ const BotSessionMonitor = forwardRef<
loadSessions(); loadSessions();
}, [loadSessions]); }, [loadSessions]);
useEffect(() => {
setSelectedSessionId(null);
setMessagePage(0);
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
useEffect(() => { useEffect(() => {
if (selectedSessionId) { if (selectedSessionId) {
loadMessages(selectedSessionId); loadMessages(selectedSessionId, messagePage);
} else { } else {
messageRequestIdRef.current += 1;
setLoadingMessages(false);
setMessages([]); setMessages([]);
setMessageTotal(0);
setToolCalls([]); setToolCalls([]);
setExpandedToolCallIds({}); setExpandedToolCallIds({});
setFeedbackMap({}); setFeedbackMap({});
} }
}, [selectedSessionId, loadMessages]); }, [selectedSessionId, messagePage, loadMessages]);
useEffect(() => { useEffect(() => {
if (messages.length === 0 && toolCalls.length === 0) return; if (messages.length === 0 && toolCalls.length === 0) return;
@@ -552,6 +626,19 @@ const BotSessionMonitor = forwardRef<
const selectedSession = sessions.find( const selectedSession = sessions.find(
(s) => s.session_id === selectedSessionId, (s) => s.session_id === selectedSessionId,
); );
const sessionPageCount = Math.max(
1,
Math.ceil(sessionTotal / SESSION_PAGE_SIZE),
);
const messagePageCount = Math.max(
1,
Math.ceil(messageTotal / MESSAGE_PAGE_SIZE),
);
const applyUserSearch = () => {
setSessionPage(0);
setAppliedUserQuery(userQuery.trim());
};
return ( return (
<> <>
@@ -575,6 +662,65 @@ const BotSessionMonitor = forwardRef<
)} )}
</span> </span>
</button> </button>
<span className="text-[11px] text-muted-foreground tabular-nums">
{t('bots.sessionMonitor.totalSessions', {
defaultValue: '{{count}} sessions',
count: sessionTotal,
})}
</span>
</div>
<div className="p-1.5 border-b shrink-0 space-y-1.5">
<div className="flex gap-1">
<input
value={userQuery}
onChange={(event) => setUserQuery(event.target.value)}
onKeyDown={(event) =>
event.key === 'Enter' && applyUserSearch()
}
aria-label={t('bots.sessionMonitor.userSearch', {
defaultValue: 'User ID or name',
})}
placeholder={t('bots.sessionMonitor.userSearch', {
defaultValue: 'User ID or name',
})}
className="h-7 min-w-0 flex-1 rounded border bg-background px-2 text-xs"
/>
<button
type="button"
onClick={applyUserSearch}
className="h-7 rounded border px-2 text-[11px] hover:bg-accent"
>
{t('common.search', { defaultValue: 'Search' })}
</button>
</div>
<div className="grid grid-cols-2 gap-1">
<input
type="date"
value={startDate}
max={endDate || undefined}
onChange={(event) => {
setSessionPage(0);
setStartDate(event.target.value);
}}
aria-label={t('bots.sessionMonitor.startDate', {
defaultValue: 'Start date',
})}
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
/>
<input
type="date"
value={endDate}
min={startDate || undefined}
onChange={(event) => {
setSessionPage(0);
setEndDate(event.target.value);
}}
aria-label={t('bots.sessionMonitor.endDate', {
defaultValue: 'End date',
})}
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
/>
</div>
</div> </div>
{/* Session List */} {/* Session List */}
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
@@ -601,7 +747,10 @@ const BotSessionMonitor = forwardRef<
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer', 'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
isSelected ? 'bg-accent' : 'hover:bg-accent/50', isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)} )}
onClick={() => setSelectedSessionId(session.session_id)} onClick={() => {
setSelectedSessionId(session.session_id);
setMessagePage(0);
}}
> >
<div className="flex items-center justify-between mb-0.5"> <div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium truncate mr-2"> <span className="text-sm font-medium truncate mr-2">
@@ -637,6 +786,29 @@ const BotSessionMonitor = forwardRef<
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
<div className="h-8 border-t px-1.5 flex items-center justify-between shrink-0 text-[11px]">
<button
type="button"
aria-label={t('common.previous', { defaultValue: 'Previous' })}
disabled={sessionPage === 0 || loadingSessions}
onClick={() => setSessionPage((page) => Math.max(0, page - 1))}
className="p-1 rounded hover:bg-accent disabled:opacity-40"
>
<ChevronLeft className="size-3.5" />
</button>
<span className="tabular-nums text-muted-foreground">
{sessionPage + 1} / {sessionPageCount}
</span>
<button
type="button"
aria-label={t('common.next', { defaultValue: 'Next' })}
disabled={sessionPage + 1 >= sessionPageCount || loadingSessions}
onClick={() => setSessionPage((page) => page + 1)}
className="p-1 rounded hover:bg-accent disabled:opacity-40"
>
<ChevronRight className="size-3.5" />
</button>
</div>
</div> </div>
{/* Right Panel: Messages */} {/* Right Panel: Messages */}
@@ -975,6 +1147,33 @@ const BotSessionMonitor = forwardRef<
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
<div className="h-9 border-t px-3 flex items-center justify-center gap-3 shrink-0 text-xs">
<button
type="button"
disabled={messagePage === 0 || loadingMessages}
onClick={() =>
setMessagePage((page) => Math.max(0, page - 1))
}
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
>
<ChevronLeft className="size-3.5" />
{t('common.previous', { defaultValue: 'Previous' })}
</button>
<span className="tabular-nums text-muted-foreground">
{messagePage + 1} / {messagePageCount} · {messageTotal}
</span>
<button
type="button"
disabled={
messagePage + 1 >= messagePageCount || loadingMessages
}
onClick={() => setMessagePage((page) => page + 1)}
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
>
{t('common.next', { defaultValue: 'Next' })}
<ChevronRight className="size-3.5" />
</button>
</div>
</> </>
)} )}
</div> </div>
+18 -4
View File
@@ -474,8 +474,13 @@ export class BackendClient extends BaseHttpClient {
public getBotSessions( public getBotSessions(
botId: string, botId: string,
limit: number = 100, options: {
offset: number = 0, limit: number;
offset: number;
startTime?: string;
endTime?: string;
userQuery?: string;
},
): Promise<{ ): Promise<{
sessions: Array<{ sessions: Array<{
session_id: string; session_id: string;
@@ -495,8 +500,17 @@ export class BackendClient extends BaseHttpClient {
}> { }> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
queryParams.append('botId', botId); queryParams.append('botId', botId);
queryParams.append('limit', limit.toString()); queryParams.append('limit', options.limit.toString());
queryParams.append('offset', offset.toString()); queryParams.append('offset', options.offset.toString());
if (options.startTime) {
queryParams.append('startTime', options.startTime);
}
if (options.endTime) {
queryParams.append('endTime', options.endTime);
}
if (options.userQuery) {
queryParams.append('userQuery', options.userQuery);
}
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`); return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
} }
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const repoRoot = path.resolve(root, '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const readRepo = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8');
const includes = (source, token, message) =>
assert.ok(source.includes(token), message);
test('session list request supports a server-side page and operator filters', () => {
const client = read('src/app/infra/http/BackendClient.ts');
includes(client, 'startTime?: string', 'client accepts a start date');
includes(client, 'endTime?: string', 'client accepts an end date');
includes(client, 'userQuery?: string', 'client accepts a user query');
includes(
client,
"queryParams.append('offset', options.offset.toString())",
'client sends the requested session offset',
);
includes(
client,
"queryParams.append('userQuery', options.userQuery)",
'client sends the user query',
);
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
for (const token of [
'SESSION_PAGE_SIZE',
'sessionTotal',
'sessionPage',
'startDate',
'endDate',
'userQuery',
]) {
includes(monitor, token, `monitor includes ${token}`);
}
});
test('session detail requests and renders a bounded message page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
for (const token of [
'MESSAGE_PAGE_SIZE',
'messageTotal',
'messagePage',
'page * MESSAGE_PAGE_SIZE',
]) {
includes(monitor, token, `message pagination includes ${token}`);
}
});
test('backend filters sessions by user id or user name in the existing endpoint', () => {
const controller = readRepo(
'src/langbot/pkg/api/http/controller/groups/monitoring.py',
);
const service = readRepo('src/langbot/pkg/api/http/service/monitoring.py');
includes(
controller,
"quart.request.args.get('userQuery')",
'route accepts userQuery',
);
includes(controller, 'user_query=user_query', 'route forwards userQuery');
includes(
service,
'user_query: str | None = None',
'service accepts userQuery',
);
includes(
service,
'MonitoringSession.user_id.ilike',
'service searches user ids',
);
includes(
service,
'MonitoringSession.user_name.ilike',
'service searches user names',
);
});
test('stale session and message page responses cannot overwrite the latest page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
for (const token of [
'sessionRequestIdRef',
'messageRequestIdRef',
'requestId !== sessionRequestIdRef.current',
'requestId !== messageRequestIdRef.current',
'messageRequestIdRef.current += 1',
]) {
includes(monitor, token, `stale response guard includes ${token}`);
}
});
test('changing the session page or filters clears the selected detail', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(monitor, 'setSelectedSessionId(null)', 'selection is cleared');
includes(
monitor,
'[appliedUserQuery, botId, endDate, sessionPage, startDate]',
'page and filters invalidate the selected session',
);
});
test('date filters use the operator local calendar day', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(
monitor,
'localDateBoundaryToISOString(startDate, false)',
'local start-of-day conversion',
);
includes(
monitor,
'localDateBoundaryToISOString(endDate, true)',
'local end-of-day conversion',
);
});
test('session tool calls are bounded to the visible message page', () => {
const monitor = read(
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
);
includes(monitor, "analysisParams.set('startTime'", 'analysis page start');
includes(monitor, "analysisParams.set('endTime'", 'analysis page end');
});