mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
+24
-4
@@ -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)
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+2
-1
@@ -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">“{submittedQuery}”</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),
|
||||
|
||||
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user