diff --git a/docs/backend/session-annotations.md b/docs/backend/session-annotations.md index 0cbd8fa4..1564f1d5 100644 --- a/docs/backend/session-annotations.md +++ b/docs/backend/session-annotations.md @@ -78,7 +78,16 @@ in the tenant-visible audit log — same convention as GA session-report submiss * **Portal**: `SessionAnnotationsCard` on the session detail page (`#section-annotations`, after Analysis; registered in BOTH the section div and the `sessionSections` sidebar registry). The card mirrors the matrix via the pure module `sessionAnnotationLogic.ts` (vitest-pinned) and - uses the explicit-Save pattern; the backend re-gates every save. + uses the explicit-Save pattern; the backend re-gates every save. Collapsed by default with a + count + verdict-pill summary header. +* **Portal overview page** `/annotations` (Monitoring sidebar group — deliberately NO new + "Insights" group; Fleet Health & co. are insights too and a second analytics group would + dilute both): tenant-scoped list backed by `GET session-annotations` + (`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The tenant is always the + middleware-validated `TargetTenantId`, and for callers without global scope the repository + adds a server-side `Lane ne 'globaladmin'` OData clause — hidden rows never consume page + budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows + deep-link to `sessions?id=…#section-annotations`. * **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 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs index 895b6109..285a6b78 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableSessionAnnotationRepositoryTests.cs @@ -141,4 +141,25 @@ public class TableSessionAnnotationRepositoryTests Assert.Equal("PartitionKey eq 'ten''ant'", filter); } + + [Fact] + public void BuildQueryFilter_excludes_globaladmin_lane_for_tenant_scope() + { + var filter = TableSessionAnnotationRepository.BuildQueryFilter( + TenantId, null, null, null, null, excludeGlobalAdminLane: true); + + Assert.Equal($"PartitionKey eq '{TenantId}' and Lane ne 'globaladmin'", filter); + } + + [Fact] + public void BuildQueryFilter_explicit_globaladmin_lane_with_exclusion_contradicts_to_empty() + { + // A tenant caller explicitly asking for lane=globaladmin gets a self-contradicting + // filter (eq + ne on the same value) — structurally an empty result, never a leak. + var filter = TableSessionAnnotationRepository.BuildQueryFilter( + TenantId, AnnotationLanes.GlobalAdmin, null, null, null, excludeGlobalAdminLane: true); + + Assert.Contains("Lane eq 'globaladmin'", filter); + Assert.Contains("Lane ne 'globaladmin'", filter); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs index b6f218b5..ecd5ded2 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs @@ -126,9 +126,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage DateTime? dateFrom, DateTime? dateTo, int pageSize, - string? continuation) + string? continuation, + bool excludeGlobalAdminLane = false) { - var filter = BuildQueryFilter(tenantId, lane, verdict, dateFrom, dateTo); + var filter = BuildQueryFilter(tenantId, lane, verdict, dateFrom, dateTo, excludeGlobalAdminLane); var items = new List(); var token = continuation; @@ -165,7 +166,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage internal static string BuildRowKey(string sessionId, string lane) => $"{sessionId}_{lane}"; internal static string? BuildQueryFilter( - string? tenantId, string? lane, string? verdict, DateTime? dateFrom, DateTime? dateTo) + string? tenantId, string? lane, string? verdict, DateTime? dateFrom, DateTime? dateTo, + bool excludeGlobalAdminLane = false) { var sb = new StringBuilder(); void Append(string clause) @@ -176,6 +178,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage if (!string.IsNullOrEmpty(tenantId)) Append($"PartitionKey eq '{Escape(tenantId!)}'"); if (!string.IsNullOrEmpty(lane)) Append($"Lane eq '{Escape(lane!)}'"); + // Server-side, so hidden rows never consume page budget; combined with an explicit + // lane=globaladmin filter this contradicts to an empty result — fail-safe by shape. + if (excludeGlobalAdminLane) Append($"Lane ne '{AnnotationLanes.GlobalAdmin}'"); if (!string.IsNullOrEmpty(verdict)) Append($"Verdict eq '{Escape(verdict!)}'"); if (dateFrom.HasValue) Append($"UpdatedAtUtc ge datetime'{dateFrom.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'"); if (dateTo.HasValue) Append($"UpdatedAtUtc lt datetime'{dateTo.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'"); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListTenantSessionAnnotationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListTenantSessionAnnotationsFunction.cs new file mode 100644 index 00000000..266ca22b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ListTenantSessionAnnotationsFunction.cs @@ -0,0 +1,116 @@ +using System; +using System.Linq; +using System.Net; +using System.Web; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Annotations +{ + /// + /// Tenant-scoped annotation list — backs the portal's Annotations overview page + /// ("which sessions has my team already judged?"). Same filter surface as the global + /// evaluation stream, always bound to the middleware-validated target tenant. The + /// platform-internal globaladmin lane is excluded in the OData query for callers + /// without global scope, so hidden rows never consume page budget. + /// + public class ListTenantSessionAnnotationsFunction + { + private readonly ILogger _logger; + private readonly ISessionAnnotationRepository _annotationRepo; + + public ListTenantSessionAnnotationsFunction( + ILogger logger, + ISessionAnnotationRepository annotationRepo) + { + _logger = logger; + _annotationRepo = annotationRepo; + } + + [Function("ListTenantSessionAnnotations")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "session-annotations")] HttpRequestData req) + { + try + { + // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware + var requestCtx = req.GetRequestContext(); + var callerTenantId = TenantHelper.GetTenantId(req); + + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + var parsedRaw = SessionAnnotationsPagination.ParseQuery(query); + if (parsedRaw.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = parsedRaw.Error }); + return bad; + } + + // The tenant filter is never caller-chosen here: TargetTenantId is the + // middleware-validated scope (own tenant; the ?tenantId drill-in is validated + // there too). A body/query value can never widen it. + var parsed = new SessionAnnotationsPagination.Parsed + { + FilterTenantId = requestCtx.TargetTenantId, + Lane = parsedRaw.Lane, + Verdict = parsedRaw.Verdict, + RuleId = parsedRaw.RuleId, + DateFrom = parsedRaw.DateFrom, + DateTo = parsedRaw.DateTo, + PageSize = parsedRaw.PageSize, + Continuation = parsedRaw.Continuation, + }; + + string? azureToken = null; + if (parsed.Continuation != null) + { + if (!SessionAnnotationsPagination.TryAcceptContinuation( + parsed, callerTenantId, out azureToken, out var rejectReason)) + { + _logger.LogWarning("ListTenantSessionAnnotations: continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var (items, nextRawToken) = await _annotationRepo.QueryPageAsync( + parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId, + parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken, + excludeGlobalAdminLane: !requestCtx.HasGlobalScope); + + string? nextLink = null; + if (!string.IsNullOrEmpty(nextRawToken)) + { + var wireToken = SessionAnnotationsPagination.EncodeContinuation( + parsed, callerTenantId, nextRawToken!); + nextLink = SessionAnnotationsPagination.BuildNextLink( + parsed, wireToken, SessionAnnotationsPagination.TenantBasePath); + } + + return await req.OkAsync(new + { + success = true, + count = items.Count, + annotations = items.Select(AnnotationWire.ToWireWithScope).ToList(), + nextLink, + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing tenant session annotations"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." }); + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs index 61fc6bfd..3eea74b8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs @@ -108,9 +108,12 @@ namespace AutopilotMonitor.Functions.Pagination return ContinuationToken.Encode(rawAzureToken, callerTenantId, fp); } - public static string BuildNextLink(Parsed parsed, string wireContinuation) + public const string GlobalBasePath = "/api/global/session-annotations"; + public const string TenantBasePath = "/api/session-annotations"; + + public static string BuildNextLink(Parsed parsed, string wireContinuation, string basePath = GlobalBasePath) { - var sb = new StringBuilder("/api/global/session-annotations"); + var sb = new StringBuilder(basePath); sb.Append("?pageSize=").Append(parsed.PageSize.ToString(CultureInfo.InvariantCulture)); sb.Append("&continuation=").Append(Uri.EscapeDataString(wireContinuation)); AppendIfSet(sb, "tenantId", parsed.FilterTenantId); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 6190fba7..ca7de6c0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -236,8 +236,10 @@ public static class EndpointAccessPolicyCatalog new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam), // Member tier: annotation reads are safe for every tenant member because the handler // filters the platform-internal globaladmin lane for callers without global scope - // (GetSessionAnnotationsFunction.FilterLanesForCaller). + // (GetSessionAnnotationsFunction.FilterLanesForCaller; the tenant list excludes the + // lane server-side in the OData query). new("GET", "sessions/{sessionId}/annotations", EndpointPolicy.MemberRead, TenantScoping.QueryParam), + new("GET", "session-annotations", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/time-attribution", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "metrics/app", EndpointPolicy.MemberRead), diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs index ada34c71..48bf1c01 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionAnnotationRepository.cs @@ -34,6 +34,11 @@ namespace AutopilotMonitor.Shared.DataAccess /// (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). + /// + /// True for callers without global scope: adds a server-side Lane ne 'globaladmin' + /// clause so the platform-internal lane never consumes page budget (and an explicit + /// =globaladmin filter simply yields an empty page). + /// Task<(List Items, string? NextRawToken)> QueryPageAsync( string? tenantId, string? lane, @@ -42,6 +47,7 @@ namespace AutopilotMonitor.Shared.DataAccess DateTime? dateFrom, DateTime? dateTo, int pageSize, - string? continuation); + string? continuation, + bool excludeGlobalAdminLane = false); } } diff --git a/src/Web/autopilot-monitor-web/app/annotations/page.tsx b/src/Web/autopilot-monitor-web/app/annotations/page.tsx new file mode 100644 index 00000000..9cab2842 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/annotations/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { sessionUrl } from "@/lib/routes"; +import { api } from "@/lib/api"; +import { API_BASE_URL } from "@/utils/config"; +import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import { ProtectedRoute } from "@/components/ProtectedRoute"; +import { useAuth } from "@/contexts/AuthContext"; +import { + ANNOTATION_VERDICTS, + LANE_LABELS, + VERDICT_DESCRIPTIONS, + VERDICT_LABELS, + VERDICT_PILL_CLASSES, + visibleLanes, + type AnnotationLane, + type AnnotationVerdict, +} from "../sessions/components/sessionAnnotationLogic"; + +/** + * Annotations overview: every annotated session of the tenant 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 backend excludes the platform-internal globaladmin lane for tenant callers. + */ + +interface AnnotationRow { + tenantId?: string | null; + sessionId: string; + lane: string; + verdict?: string | null; + note?: string | null; + authorDisplayName?: string | null; + updatedAtUtc?: string | null; + ruleIds?: string[] | null; +} + +interface AnnotationsListResponse { + success: boolean; + annotations?: AnnotationRow[] | null; + nextLink?: string | null; +} + +export default function AnnotationsPage() { + const { getAccessToken, user } = useAuth(); + + const [rows, setRows] = useState([]); + const [nextLink, setNextLink] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [verdictFilter, setVerdictFilter] = useState(""); + const [laneFilter, setLaneFilter] = useState(""); + + const lanes = visibleLanes(user); + + const fetchPage = useCallback( + async (url: string, append: boolean) => { + setLoading(true); + setLoadError(null); + try { + const res = await authenticatedFetch(url, getAccessToken); + if (!res.ok) throw new Error(`Failed to load annotations: ${res.statusText}`); + const json = (await res.json()) as AnnotationsListResponse; + setRows((prev) => (append ? [...prev, ...(json.annotations ?? [])] : (json.annotations ?? []))); + setNextLink(json.nextLink ?? null); + } catch (err) { + setLoadError(err instanceof Error ? err.message : "Failed to load annotations"); + } finally { + setLoading(false); + } + }, + [getAccessToken] + ); + + useEffect(() => { + fetchPage( + api.annotations.list({ + verdict: verdictFilter || undefined, + lane: laneFilter || undefined, + }), + false + ); + }, [fetchPage, verdictFilter, laneFilter]); + + return ( + +
+
+
+

Annotations

+

+ Every session your team has judged — open one to read or update the verdict. +

+
+
+ +
+
+
+ + +
+ + {loadError ? ( +

{loadError}

+ ) : rows.length === 0 && !loading ? ( +

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

+ ) : ( +
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
VerdictRoleNoteAuthorUpdated
+ {row.verdict != null ? ( + + {VERDICT_LABELS[row.verdict as AnnotationVerdict] ?? row.verdict} + + ) : ( + note only + )} + + {LANE_LABELS[row.lane as AnnotationLane] ?? row.lane} + + {row.note || "—"} + + {row.authorDisplayName || "—"} + + {row.updatedAtUtc ? new Date(row.updatedAtUtc).toLocaleString() : "—"} + + + open session + +
+
+ )} + +
+ {loading && Loading…} + {!loading && nextLink && ( + + )} +
+
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx index da5283d2..406063e3 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionAnnotationsCard.tsx @@ -12,6 +12,7 @@ import { LANE_LABELS, VERDICT_DESCRIPTIONS, VERDICT_LABELS, + VERDICT_PILL_CLASSES, visibleLanes, validateNote, type AnnotationLane, @@ -33,13 +34,6 @@ interface AnnotationsResponse { annotations?: SessionAnnotationDto[] | null; } -const VERDICT_PILL: Record = { - root_cause_confirmed: "bg-green-100 text-green-800", - analysis_wrong: "bg-red-100 text-red-800", - different_problem: "bg-amber-100 text-amber-800", - inconclusive: "bg-slate-100 text-slate-600", -}; - interface LaneEditState { verdict: string | null; note: string; @@ -181,7 +175,7 @@ export default function SessionAnnotationsCard({ return verdict != null ? ( {VERDICT_LABELS[verdict as AnnotationVerdict] ?? verdict} @@ -221,7 +215,7 @@ export default function SessionAnnotationsCard({ {annotation?.verdict != null && !writable && ( {VERDICT_LABELS[annotation.verdict as AnnotationVerdict] ?? annotation.verdict} @@ -251,7 +245,7 @@ export default function SessionAnnotationsCard({ } className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${ selected - ? `${VERDICT_PILL[verdict]} border-transparent ring-1 ring-inset ring-gray-300` + ? `${VERDICT_PILL_CLASSES[verdict]} border-transparent ring-1 ring-inset ring-gray-300` : "bg-white text-gray-600 border-gray-300 hover:bg-gray-50" }`} > diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts b/src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts index 63c8ce77..b4a594fd 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts @@ -57,6 +57,14 @@ export const VERDICT_DESCRIPTIONS: Record = { inconclusive: "The investigation ended without a clear answer", }; +/** Shared verdict pill styling (session card + annotations overview page). */ +export const VERDICT_PILL_CLASSES: Record = { + root_cause_confirmed: "bg-green-100 text-green-800", + analysis_wrong: "bg-red-100 text-red-800", + different_problem: "bg-amber-100 text-amber-800", + inconclusive: "bg-slate-100 text-slate-600", +}; + export const LANE_LABELS: Record = { operator: "Operator", tenantadmin: "Tenant Admin", diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index d78a93d9..b03add3d 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -377,6 +377,26 @@ export const api = { `${API_BASE_URL}/api/bootstrap/sessions/${code}${qs({ tenantId })}`, }, + // ── Annotations (tenant-scoped overview list) ───────────────────────────── + annotations: { + // GET — the tenant's annotation rows, newest-first per partition order; the + // backend excludes the platform-internal globaladmin lane for tenant callers. + list: (opts?: { + tenantId?: string; + lane?: string; + verdict?: string; + pageSize?: number; + continuation?: string; + }) => + `${API_BASE_URL}/api/session-annotations${qs({ + tenantId: opts?.tenantId, + lane: opts?.lane, + verdict: opts?.verdict, + pageSize: opts?.pageSize?.toString(), + continuation: opts?.continuation, + })}`, + }, + // ── Reports ─────────────────────────────────────────────────────────────── reports: { list: (opts?: { tenantId?: string; pageSize?: number; continuation?: string }) => diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index 621ac6e0..1b826c3d 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -7,6 +7,7 @@ import { SparklesIcon, FolderIcon, WrenchScrewdriverIcon, + PencilSquareIcon, } from "./sidebarIcons"; import type { Route } from "next"; import { route } from "./routes"; @@ -160,6 +161,7 @@ export const NAV_GROUPS: NavGroup[] = [ visibility: "adminOrOperator", items: [ { id: "progress", label: "Progress Portal", href: "/progress", icon: }, + { id: "annotations", label: "Annotations", href: "/annotations", icon: }, { id: "geographic-performance", label: "Geographic Perf.", href: "/geographic-performance", icon: }, { id: "fleet-health", label: "Fleet Health", href: "/fleet-health", icon: }, { id: "sla", label: "SLA Compliance", href: "/sla", icon: },