diff --git a/docs/backend/session-annotations.md b/docs/backend/session-annotations.md index bb000b18..5659832e 100644 --- a/docs/backend/session-annotations.md +++ b/docs/backend/session-annotations.md @@ -98,13 +98,18 @@ in the tenant-visible audit log — same convention as GA session-report submiss "All tenants" aggregate (the portal twin of MCP's `list_session_annotations`) with per-tenant drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route to `GET global/session-annotations`, a tenant column appears, and deep links carry the row's - `tenantId`. + `tenantId`. A free-text search box (`?q=`, debounced) matches the note and the verdict + case-insensitively — the way a session that has fallen out of every time window is found + again: describe it once in a note ("wifi switch"), then search for that. Deliberately not the + lane, author or rule ids, which have their own filters. * **MCP read**: `get_session_summary` carries an `annotations` key (4th parallel leg, fail-soft null); `list_session_annotations` (`ga`-gated) is the evaluation stream with `tenantId / lane / - verdict / ruleId / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` filter is - applied server-side but client-of-Azure (substring on the JSON column), so the backend - back-fills short pages by looping the Azure continuation (bounded rounds) — filtered-out rows - never consume page budget. + verdict / ruleId / query / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` + filter (substring on the JSON column) and the `query` note search (`q` on the wire, capped at + 200 characters) are applied server-side but client-of-Azure, so the backend back-fills short + pages by looping the Azure continuation (bounded rounds) — filtered-out rows never consume page + budget. Both are bound into the continuation fingerprint, so a cursor issued for one search + cannot be replayed against another. * **MCP write**: `annotate_session` (`strictGa`-gated, MUTATING) always PUTs the `globaladmin` lane — no lane argument by design. This is the frictionless labeling path after a session-debug. * **Session reports**: `SessionReportService.SubmitReportAsync` snapshots ALL lanes into diff --git a/docs/log.md b/docs/log.md index 4e77468e..ea17e3d2 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,10 @@ # Log +## 2026-09-01 (7) + +* **Update**: `backend/session-annotations.md` - the annotation lists gain a free-text note search: `?q=` on both `sessions/annotations/list` and `global/session-annotations` (MCP `list_session_annotations` `query`), a case-insensitive substring over note + verdict, applied in the same client-of-Azure back-fill loop as `ruleId` and bound into the continuation fingerprint. Portal: a debounced search box on the Annotations page. Motivation: demo sessions imported into the operator's own tenant fall out of every time window; a note ("wifi switch") plus this search is how they are found again. + + ## 2026-09-01 (8) * **Update**: `backend/raw-events-scan-budget.md` - the SessionsIndex-scan-per-session trap had two more live callers: `search_sessions_by_cve` (CveIndex page) and the deviceProperties path of `search_sessions` (DeviceSnapshot page), each up to 1000 full-table scans per cross-tenant page. Both now point-read Sessions with the tenant their index row carries (`BatchGetSessionsByKeyAsync`, `IndexRowKeys.ResolveTenantId`); the scan-based batch helper is deleted. MCP: every nextLink-paginated tool applies an explicit pageSize (and fields on the raw tools) to a continuation, pageSize schemas lost their `.default()` (`pageSizeForCall` sends the first-page default only on a first-page call), and the three auto-exhaust tools retry a timeout once with a halved page. The doc gains the audit of the remaining unbounded cross-tenant aggregations with their live p95 durations (5-15 s today, no cap, no cursor). diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs index 62325370..c250894d 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationFunctionsTests.cs @@ -189,10 +189,10 @@ public class SessionAnnotationFunctionsTests annotationRepo .Setup(r => r.QueryPageAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny())) - .Callback( - (_, _, _, _, _, _, _, _, exclude) => capturedExclude = exclude) + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, _, _, _, _, _, _, _, _, exclude) => capturedExclude = exclude) .ReturnsAsync((new List(), (string?)null)); var function = new ListSessionAnnotationsFunction( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationsPaginationTests.cs new file mode 100644 index 00000000..071262af --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionAnnotationsPaginationTests.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Specialized; +using AutopilotMonitor.Functions.Pagination; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The annotation list's free-text note search (?q=): parsed, bound into the +/// continuation fingerprint (a cursor issued for one search must not be replayed against +/// another), and echoed on nextLink so a client following the link keeps the filter. +/// +public class SessionAnnotationsPaginationTests +{ + private const string Caller = "b54dc1af-5320-4f60-b5d4-821e0cf2a359"; + + [Fact] + public void ParseQuery_reads_and_trims_q() + { + var parsed = SessionAnnotationsPagination.ParseQuery(new NameValueCollection { { "q", " wifi switch " } }); + Assert.Null(parsed.Error); + Assert.Equal("wifi switch", parsed.Query); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ParseQuery_blank_q_is_absent(string raw) + { + var parsed = SessionAnnotationsPagination.ParseQuery(new NameValueCollection { { "q", raw } }); + Assert.Null(parsed.Error); + Assert.Null(parsed.Query); + } + + [Fact] + public void ParseQuery_rejects_overlong_q() + { + var raw = new string('x', SessionAnnotationsPagination.MaxQueryLength + 1); + var parsed = SessionAnnotationsPagination.ParseQuery(new NameValueCollection { { "q", raw } }); + Assert.NotNull(parsed.Error); + Assert.Contains("q must be at most", parsed.Error); + } + + [Fact] + public void Continuation_issued_for_one_search_is_rejected_for_another() + { + var wifi = SessionAnnotationsPagination.ParseQuery(new NameValueCollection { { "q", "wifi" } }); + var token = SessionAnnotationsPagination.EncodeContinuation(wifi, Caller, "raw-azure-token"); + + var lan = SessionAnnotationsPagination.ParseQuery(new NameValueCollection + { + { "q", "lan" }, { "continuation", token }, + }); + Assert.False(SessionAnnotationsPagination.TryAcceptContinuation(lan, Caller, out _, out var reason)); + Assert.NotNull(reason); + + var none = SessionAnnotationsPagination.ParseQuery(new NameValueCollection { { "continuation", token } }); + Assert.False(SessionAnnotationsPagination.TryAcceptContinuation(none, Caller, out _, out _)); + } + + [Fact] + public void Continuation_round_trips_for_the_same_search() + { + var first = SessionAnnotationsPagination.ParseQuery(new NameValueCollection { { "q", "wifi" } }); + var token = SessionAnnotationsPagination.EncodeContinuation(first, Caller, "raw-azure-token"); + + var next = SessionAnnotationsPagination.ParseQuery(new NameValueCollection + { + { "q", "wifi" }, { "continuation", token }, + }); + Assert.True(SessionAnnotationsPagination.TryAcceptContinuation(next, Caller, out var azureToken, out _)); + Assert.Equal("raw-azure-token", azureToken); + } + + [Fact] + public void NextLink_echoes_q_url_encoded() + { + var parsed = SessionAnnotationsPagination.ParseQuery(new NameValueCollection { { "q", "wifi switch & lan" } }); + var link = SessionAnnotationsPagination.BuildNextLink(parsed, "wire-token", SessionAnnotationsPagination.TenantBasePath); + Assert.StartsWith(SessionAnnotationsPagination.TenantBasePath, link); + Assert.Contains("&q=" + Uri.EscapeDataString("wifi switch & lan"), link); + } + + [Fact] + public void NextLink_omits_q_when_absent() + { + var parsed = SessionAnnotationsPagination.ParseQuery(new NameValueCollection()); + var link = SessionAnnotationsPagination.BuildNextLink(parsed, "wire-token"); + Assert.DoesNotContain("&q=", link); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs index 285a6b78..b5f3f37b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs @@ -111,6 +111,58 @@ public class TableSessionAnnotationRepositoryTests // ── query filter builder ──────────────────────────────────────────────── + // ── free-text note search (client-side, like ruleId) ───────────────────── + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void MatchesQuery_blank_query_matches_every_row(string? query) + { + var a = new SessionAnnotation { Note = null, Verdict = null }; + Assert.True(TableSessionAnnotationRepository.MatchesQuery(a, query)); + } + + [Theory] + [InlineData("wifi")] + [InlineData("WIFI")] + [InlineData(" WiFi switch ")] + public void MatchesQuery_matches_note_substring_case_insensitively(string query) + { + var a = new SessionAnnotation { Note = "Nice demo: WiFi switch at minute 3, then LAN.", Verdict = null }; + Assert.True(TableSessionAnnotationRepository.MatchesQuery(a, query)); + } + + [Fact] + public void MatchesQuery_matches_verdict_too() + { + var a = new SessionAnnotation { Note = null, Verdict = "analysis_wrong" }; + Assert.True(TableSessionAnnotationRepository.MatchesQuery(a, "wrong")); + } + + [Fact] + public void MatchesQuery_ignores_lane_author_and_rule_ids() + { + var a = new SessionAnnotation + { + Note = "plain note", + Verdict = null, + Lane = "tenantadmin", + AuthorDisplayName = "Leia Organa", + RuleIds = new List { "ANALYZE-ESP-001" }, + }; + Assert.False(TableSessionAnnotationRepository.MatchesQuery(a, "tenantadmin")); + Assert.False(TableSessionAnnotationRepository.MatchesQuery(a, "Leia")); + Assert.False(TableSessionAnnotationRepository.MatchesQuery(a, "ESP-001")); + } + + [Fact] + public void MatchesQuery_no_hit_returns_false() + { + var a = new SessionAnnotation { Note = "ethernet only", Verdict = "root_cause_confirmed" }; + Assert.False(TableSessionAnnotationRepository.MatchesQuery(a, "wifi")); + } + [Fact] public void BuildQueryFilter_no_filters_returns_null() { diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs index ecd5ded2..274b26ce 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs @@ -123,6 +123,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage string? lane, string? verdict, string? ruleId, + string? query, DateTime? dateFrom, DateTime? dateTo, int pageSize, @@ -133,10 +134,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage var items = new List(); var token = continuation; - // ruleId cannot be pushed into OData (substring match on a JSON column), so it - // filters client-side. Back-fill whole Azure pages until pageSize matches - // accumulate or the scan is exhausted, so filtered-out rows never consume page - // budget (lesson: filter-after-pagination empties the list). + // ruleId (substring match on a JSON column) and the free-text note search cannot + // be pushed into OData, so they filter client-side. Back-fill whole Azure pages + // until pageSize matches accumulate or the scan is exhausted, so filtered-out + // rows never consume page budget (lesson: filter-after-pagination empties the list). for (var round = 0; round < MaxBackfillRoundTrips; round++) { var (page, nextToken) = await AzureTablesPaginator.FetchPageAsync( @@ -150,6 +151,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { continue; } + if (!MatchesQuery(annotation, query)) + { + continue; + } items.Add(annotation); } @@ -165,6 +170,21 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage internal static string BuildRowKey(string sessionId, string lane) => $"{sessionId}_{lane}"; + /// + /// Free-text search: case-insensitive substring on the note and on the verdict — + /// the two fields a person writes or picks to describe a session ("wifi switch", + /// "analysis_wrong"). Deliberately not the lane, the author or the rule ids: those + /// have their own filters, and a hit on an author name would surprise a reader who + /// searched a note. An absent/blank query matches every row. + /// + internal static bool MatchesQuery(SessionAnnotation annotation, string? query) + { + if (string.IsNullOrWhiteSpace(query)) return true; + var q = query.Trim(); + return (annotation.Note?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) + || (annotation.Verdict?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false); + } + internal static string? BuildQueryFilter( string? tenantId, string? lane, string? verdict, DateTime? dateFrom, DateTime? dateTo, bool excludeGlobalAdminLane = false) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListSessionAnnotationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListSessionAnnotationsFunction.cs index 4e351c62..c280bf62 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListSessionAnnotationsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListSessionAnnotationsFunction.cs @@ -71,7 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Annotations // delegated caller on its managed ?tenantId= path. The platform-internal globaladmin // lane stays global-scope-only, so it is excluded for everyone else. var (items, nextRawToken) = await _annotationRepo.QueryPageAsync( - parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId, + parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId, parsed.Query, parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken, excludeGlobalAdminLane: !requestCtx.HasGlobalScope); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListTenantSessionAnnotationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListTenantSessionAnnotationsFunction.cs index 10209ae8..727947de 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListTenantSessionAnnotationsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListTenantSessionAnnotationsFunction.cs @@ -65,6 +65,7 @@ namespace AutopilotMonitor.Functions.Functions.Annotations Lane = parsedRaw.Lane, Verdict = parsedRaw.Verdict, RuleId = parsedRaw.RuleId, + Query = parsedRaw.Query, DateFrom = parsedRaw.DateFrom, DateTo = parsedRaw.DateTo, PageSize = parsedRaw.PageSize, @@ -89,7 +90,7 @@ namespace AutopilotMonitor.Functions.Functions.Annotations } var (items, nextRawToken) = await _annotationRepo.QueryPageAsync( - parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId, + parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId, parsed.Query, parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken, excludeGlobalAdminLane: !requestCtx.HasGlobalScope); diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs index 98ea539e..1485f0a2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs @@ -20,10 +20,15 @@ namespace AutopilotMonitor.Functions.Pagination { public const int DefaultPageSize = 200; public const int MaxPageSize = 1000; + /// + /// Cap on the free-text note search (?q=). Notes themselves are capped at 4096 + /// characters; a search term longer than this is never a real query. + /// + public const int MaxQueryLength = 200; public static string Fingerprint( string callerTenantId, string? filterTenantId, string? lane, string? verdict, - string? ruleId, DateTime? dateFrom, DateTime? dateTo) => + string? ruleId, string? query, DateTime? dateFrom, DateTime? dateTo) => ContinuationToken.ComputeFingerprint(new[] { new KeyValuePair("scope", "session-annotations"), @@ -32,6 +37,7 @@ namespace AutopilotMonitor.Functions.Pagination new KeyValuePair("lane", lane), new KeyValuePair("verdict", verdict), new KeyValuePair("ruleId", ruleId), + new KeyValuePair("q", query), new KeyValuePair("dateFrom", dateFrom?.ToString("o", CultureInfo.InvariantCulture)), new KeyValuePair("dateTo", dateTo?.ToString("o", CultureInfo.InvariantCulture)), }); @@ -42,6 +48,11 @@ namespace AutopilotMonitor.Functions.Pagination public string? Lane { get; init; } public string? Verdict { get; init; } public string? RuleId { get; init; } + /// + /// Free-text search over the note and the verdict (case-insensitive substring), + /// matched client-side like . Trimmed; null when absent. + /// + public string? Query { get; init; } public DateTime? DateFrom { get; init; } public DateTime? DateTo { get; init; } public int PageSize { get; init; } = DefaultPageSize; @@ -75,12 +86,17 @@ namespace AutopilotMonitor.Functions.Pagination if (!TryParseDate(query?["dateTo"], out var dateTo)) return new Parsed { Error = "dateTo must be an ISO 8601 date" }; + var search = NullIfEmpty(query?["q"]?.Trim()); + if (search != null && search.Length > MaxQueryLength) + return new Parsed { Error = $"q must be at most {MaxQueryLength} characters" }; + return new Parsed { FilterTenantId = NullIfEmpty(query?["tenantId"]), Lane = lane, Verdict = verdict, RuleId = NullIfEmpty(query?["ruleId"]), + Query = search, DateFrom = dateFrom, DateTo = dateTo, PageSize = pageSize, @@ -96,7 +112,7 @@ namespace AutopilotMonitor.Functions.Pagination { var fp = Fingerprint( callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict, - parsed.RuleId, parsed.DateFrom, parsed.DateTo); + parsed.RuleId, parsed.Query, parsed.DateFrom, parsed.DateTo); return ContinuationToken.TryDecode(parsed.Continuation!, callerTenantId, fp, out azureToken, out rejectReason); } @@ -104,7 +120,7 @@ namespace AutopilotMonitor.Functions.Pagination { var fp = Fingerprint( callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict, - parsed.RuleId, parsed.DateFrom, parsed.DateTo); + parsed.RuleId, parsed.Query, parsed.DateFrom, parsed.DateTo); return ContinuationToken.Encode(rawAzureToken, callerTenantId, fp); } @@ -121,6 +137,7 @@ namespace AutopilotMonitor.Functions.Pagination AppendIfSet(sb, "lane", parsed.Lane); AppendIfSet(sb, "verdict", parsed.Verdict); AppendIfSet(sb, "ruleId", parsed.RuleId); + AppendIfSet(sb, "q", parsed.Query); AppendIfSet(sb, "dateFrom", parsed.DateFrom?.ToString("o", CultureInfo.InvariantCulture)); AppendIfSet(sb, "dateTo", parsed.DateTo?.ToString("o", CultureInfo.InvariantCulture)); return sb.ToString(); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 3c804a24..5ce7e40e 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -990,6 +990,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo .describe('Optional — filter to one structured verdict.'), ruleId: z.string().optional() .describe('Optional — only annotations whose rule-id snapshot contains this rule (e.g. "ANALYZE-ESP-001").'), + query: z.string().trim().min(1).max(200).optional() + .describe('Optional — free-text search: case-insensitive substring over the note and the verdict ' + + '(e.g. "wifi switch" finds every session someone described that way). Not matched against lane, author or rule ids.'), dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound on the last-updated time.'), dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — exclusive upper bound on the last-updated time.'), pageSize: z.coerce.number().int().min(1).max(1000).optional() @@ -1001,11 +1004,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo }, async (args) => withToolTelemetry('list_session_annotations', args, async () => { try { - const { tenantId, lane, verdict, ruleId, dateFrom, dateTo, continuation } = args; + const { tenantId, lane, verdict, ruleId, query, dateFrom, dateTo, continuation } = args; const pageSize = pageSizeForCall(args.pageSize, continuation, 200); const path = followNextLink( '/api/global/session-annotations', - { tenantId, lane, verdict, ruleId, dateFrom, dateTo, pageSize }, + { tenantId, lane, verdict, ruleId, q: query, dateFrom, dateTo, pageSize }, continuation, { pageSize }, ); diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs index 48bf1c01..eaeba5c9 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs @@ -28,10 +28,12 @@ namespace AutopilotMonitor.Shared.DataAccess /// /// Global evaluation query (cross-tenant filtered table scan; annotation volume is /// human-entered and small). All filters optional. is - /// matched client-side against , so this - /// method back-fills short pages by looping the Azure continuation until - /// matches accumulate or the scan is exhausted - /// (bounded round-trips) — a filtered-out row never consumes page budget. + /// matched client-side against and + /// is a case-insensitive substring match on the note and + /// the verdict (Table Storage has no contains), so this method back-fills + /// short pages by looping the Azure continuation until + /// matches accumulate or the scan is exhausted (bounded round-trips) — a + /// filtered-out row never consumes page budget. /// /// The page items plus the raw Azure continuation token for the next page (null when done). /// @@ -44,6 +46,7 @@ namespace AutopilotMonitor.Shared.DataAccess string? lane, string? verdict, string? ruleId, + string? query, DateTime? dateFrom, DateTime? dateTo, int pageSize, diff --git a/src/Web/autopilot-monitor-web/app/annotations/page.tsx b/src/Web/autopilot-monitor-web/app/annotations/page.tsx index a86a01e5..1cf5f1c9 100644 --- a/src/Web/autopilot-monitor-web/app/annotations/page.tsx +++ b/src/Web/autopilot-monitor-web/app/annotations/page.tsx @@ -24,8 +24,10 @@ import { /** * Annotations overview: every annotated session in one list, so a judged session can be - * found again without remembering which one it was. Server-side filters (verdict, lane) - * + nextLink pagination; rows deep-link into the session's annotation section. The + * found again without remembering which one it was. Server-side filters (verdict, lane, + * free-text note search) + nextLink pagination; rows deep-link into the session's annotation + * section. The search is how a demo session that has fallen out of every time window is + * found again: describe it once in a note ("wifi switch"), then search for that. The * backend excludes the platform-internal globaladmin lane for tenant callers. * * Cross-tenant: a GA/Reader gets the tenant switcher incl. the "All tenants" aggregate @@ -59,6 +61,16 @@ export default function AnnotationsPage() { const [loadError, setLoadError] = useState(null); const [verdictFilter, setVerdictFilter] = useState(""); const [laneFilter, setLaneFilter] = useState(""); + // Free-text search over note + verdict, server-side. The input updates per keystroke; + // the SUBMITTED value follows after a short pause so typing never fires a request per + // key (the setState lives in the timer callback, not the effect body). + const [noteQuery, setNoteQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + useEffect(() => { + const trimmed = noteQuery.trim(); + const timer = setTimeout(() => setSubmittedQuery(trimmed), 300); + return () => clearTimeout(timer); + }, [noteQuery]); // GA/Reader: "All tenants" aggregate by default (mirrors the MCP evaluation stream) with // per-tenant drill-down; delegated ("MSP"): managed tenants only, never aggregated. @@ -111,6 +123,7 @@ export default function AnnotationsPage() { const filters = { verdict: verdictFilter || undefined, lane: laneFilter || undefined, + q: submittedQuery || undefined, }; await fetchPage( scopedApi.annotationsList({ routeGlobal, selectedTenantId, effectiveTenantId }, filters), @@ -118,7 +131,7 @@ export default function AnnotationsPage() { ); }; void loadFirstPage(); - }, [fetchPage, verdictFilter, laneFilter, scopeInitialized, routeGlobal, selectedTenantId, effectiveTenantId]); + }, [fetchPage, verdictFilter, laneFilter, submittedQuery, scopeInitialized, routeGlobal, selectedTenantId, effectiveTenantId]); return ( @@ -167,16 +180,46 @@ export default function AnnotationsPage() { ))} +
+ setNoteQuery(e.target.value)} + placeholder="Search notes (e.g. wifi switch)" + maxLength={200} + aria-label="Search annotation notes" + className="w-full border border-gray-300 rounded-md px-3 py-1.5 pr-8 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500" + /> + {noteQuery && ( + + )} +
{loadError ? (

{loadError}

) : visibleRows.length === 0 && !loading ? ( -

- No annotations yet. Open a session and record a verdict in its{" "} - Annotations section — judged sessions - show up here. -

+ submittedQuery ? ( +

+ No annotation mentions “{submittedQuery}”. +

+ ) : ( +

+ No annotations yet. Open a session and record a verdict in its{" "} + Annotations section — judged sessions + show up here. +

+ ) ) : (
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts index 122dcab2..2dce12af 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts @@ -35,6 +35,12 @@ describe("scopedApi routing", () => { expect(scopedApi.annotationsList(override, {})).toContain(`tenantId=${OTHER}`); }); + it("annotationsList forwards the free-text search on both variants", () => { + expect(scopedApi.annotationsList(override, { q: "wifi switch" })).toContain("q=wifi+switch"); + expect(scopedApi.annotationsList(tenantMode, { q: "wifi switch" })).toContain("q=wifi+switch"); + expect(scopedApi.annotationsList(tenantMode, {})).not.toContain("q="); + }); + it("aggregated ('') sends the global variant WITHOUT a tenantId param", () => { for (const url of [ scopedApi.appMetrics(aggregated, 30), diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 436d6d9f..77545d0d 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -434,6 +434,8 @@ export const api = { tenantId?: string; lane?: string; verdict?: string; + /** Free-text search over note + verdict (case-insensitive substring, server-side). */ + q?: string; pageSize?: number; continuation?: string; }) => @@ -441,6 +443,7 @@ export const api = { tenantId: opts?.tenantId, lane: opts?.lane, verdict: opts?.verdict, + q: opts?.q, pageSize: opts?.pageSize?.toString(), continuation: opts?.continuation, })}`, @@ -450,6 +453,7 @@ export const api = { tenantId?: string; lane?: string; verdict?: string; + q?: string; pageSize?: number; continuation?: string; }) => @@ -457,6 +461,7 @@ export const api = { tenantId: opts?.tenantId, lane: opts?.lane, verdict: opts?.verdict, + q: opts?.q, pageSize: opts?.pageSize?.toString(), continuation: opts?.continuation, })}`, diff --git a/src/Web/autopilot-monitor-web/lib/scopedApi.ts b/src/Web/autopilot-monitor-web/lib/scopedApi.ts index 8b804ec9..97c606d8 100644 --- a/src/Web/autopilot-monitor-web/lib/scopedApi.ts +++ b/src/Web/autopilot-monitor-web/lib/scopedApi.ts @@ -79,7 +79,7 @@ export const scopedApi = { annotationsList: ( sel: TenantScopeSelection, - opts: { verdict?: string; lane?: string; pageSize?: number; continuation?: string }, + opts: { verdict?: string; lane?: string; q?: string; pageSize?: number; continuation?: string }, ) => sel.routeGlobal ? api.annotations.globalList({ ...opts, tenantId: globalTenantParam(sel) })