feat: free-text note search on the annotation lists

Both annotation list routes (sessions/annotations/list, global/session-
annotations) accept ?q= -- a case-insensitive substring over the note and
the verdict, deliberately not the lane, author or rule ids, which have their
own filters. Table Storage has no contains, so it is applied in the same
client-of-Azure back-fill loop as ruleId (filtered-out rows never consume
page budget) and bound into the continuation fingerprint, so a cursor issued
for one search cannot be replayed against another; nextLink echoes it.

Portal: a debounced search box on the Annotations page (the setState lives
in the timer callback, not the effect body). MCP: list_session_annotations
gains `query` -> q. Response DTO unchanged, no wire break.

Why: demo sessions imported into the operator's own tenant fall out of every
dashboard/geo/SLA time window. A note ("wifi switch") plus this search is
how they are found again -- and it is generally useful for any judged session.

Deploy order: backend first, then web and MCP (the parameter is silently
ignored by an older backend).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-01 22:49:35 +02:00
parent 2fe1095758
commit d6783fe327
15 changed files with 283 additions and 33 deletions
+10 -5
View File
@@ -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
+5
View File
@@ -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).
@@ -189,10 +189,10 @@ public class SessionAnnotationFunctionsTests
annotationRepo
.Setup(r => r.QueryPageAsync(
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<DateTime?>(), It.IsAny<DateTime?>(), It.IsAny<int>(), It.IsAny<string?>(),
It.IsAny<bool>()))
.Callback<string?, string?, string?, string?, DateTime?, DateTime?, int, string?, bool>(
(_, _, _, _, _, _, _, _, exclude) => capturedExclude = exclude)
It.IsAny<string?>(), It.IsAny<DateTime?>(), It.IsAny<DateTime?>(), It.IsAny<int>(),
It.IsAny<string?>(), It.IsAny<bool>()))
.Callback<string?, string?, string?, string?, string?, DateTime?, DateTime?, int, string?, bool>(
(_, _, _, _, _, _, _, _, _, exclude) => capturedExclude = exclude)
.ReturnsAsync((new List<SessionAnnotation>(), (string?)null));
var function = new ListSessionAnnotationsFunction(
@@ -0,0 +1,90 @@
using System;
using System.Collections.Specialized;
using AutopilotMonitor.Functions.Pagination;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The annotation list's free-text note search (<c>?q=</c>): 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.
/// </summary>
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);
}
}
@@ -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<string> { "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()
{
@@ -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<SessionAnnotation>();
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<TableEntity>(
@@ -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}";
/// <summary>
/// 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.
/// </summary>
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)
@@ -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);
@@ -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);
@@ -20,10 +20,15 @@ namespace AutopilotMonitor.Functions.Pagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
/// <summary>
/// Cap on the free-text note search (<c>?q=</c>). Notes themselves are capped at 4096
/// characters; a search term longer than this is never a real query.
/// </summary>
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<string, string?>("scope", "session-annotations"),
@@ -32,6 +37,7 @@ namespace AutopilotMonitor.Functions.Pagination
new KeyValuePair<string, string?>("lane", lane),
new KeyValuePair<string, string?>("verdict", verdict),
new KeyValuePair<string, string?>("ruleId", ruleId),
new KeyValuePair<string, string?>("q", query),
new KeyValuePair<string, string?>("dateFrom", dateFrom?.ToString("o", CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("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; }
/// <summary>
/// Free-text search over the note and the verdict (case-insensitive substring),
/// matched client-side like <see cref="RuleId"/>. Trimmed; null when absent.
/// </summary>
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();
@@ -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 },
);
@@ -28,10 +28,12 @@ namespace AutopilotMonitor.Shared.DataAccess
/// <summary>
/// Global evaluation query (cross-tenant filtered table scan; annotation volume is
/// human-entered and small). All filters optional. <paramref name="ruleId"/> is
/// matched client-side against <see cref="SessionAnnotation.RuleIds"/>, so this
/// method back-fills short pages by looping the Azure continuation until
/// <paramref name="pageSize"/> matches accumulate or the scan is exhausted
/// (bounded round-trips) — a filtered-out row never consumes page budget.
/// matched client-side against <see cref="SessionAnnotation.RuleIds"/> and
/// <paramref name="query"/> is a case-insensitive substring match on the note and
/// the verdict (Table Storage has no <c>contains</c>), so this method back-fills
/// short pages by looping the Azure continuation until <paramref name="pageSize"/>
/// matches accumulate or the scan is exhausted (bounded round-trips) — a
/// filtered-out row never consumes page budget.
/// </summary>
/// <returns>The page items plus the raw Azure continuation token for the next page (null when done).</returns>
/// <param name="excludeGlobalAdminLane">
@@ -44,6 +46,7 @@ namespace AutopilotMonitor.Shared.DataAccess
string? lane,
string? verdict,
string? ruleId,
string? query,
DateTime? dateFrom,
DateTime? dateTo,
int pageSize,
@@ -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<string | null>(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 (
<ProtectedRoute>
@@ -167,16 +180,46 @@ export default function AnnotationsPage() {
<option key={lane} value={lane}>{LANE_LABELS[lane]}</option>
))}
</select>
<div className="relative flex-1 min-w-[220px] max-w-md">
<input
type="search"
value={noteQuery}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setNoteQuery("")}
className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600"
aria-label="Clear search"
title="Clear search"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
{loadError ? (
<p className="text-sm text-amber-700">{loadError}</p>
) : visibleRows.length === 0 && !loading ? (
<p className="text-sm text-gray-400">
No annotations yet. Open a session and record a verdict in its{" "}
<span className="font-medium">Annotations</span> section — judged sessions
show up here.
</p>
submittedQuery ? (
<p className="text-sm text-gray-400">
No annotation mentions <span className="font-medium">&ldquo;{submittedQuery}&rdquo;</span>.
</p>
) : (
<p className="text-sm text-gray-400">
No annotations yet. Open a session and record a verdict in its{" "}
<span className="font-medium">Annotations</span> section — judged sessions
show up here.
</p>
)
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
@@ -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),
+5
View File
@@ -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,
})}`,
@@ -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) })