annotations: tenant-scoped overview page listing judged sessions

New GET /api/session-annotations (MemberRead + QueryParam, tenant always
the middleware-validated target) backing the portal /annotations page in
the Monitoring sidebar group (deliberately no new Insights group). For
callers without global scope the repository excludes the globaladmin
lane server-side (Lane ne clause), so hidden rows never consume page
budget and an explicit lane filter self-contradicts to empty. Rows
deep-link to the session annotation section. Verdict pill styling moved
to the shared logic module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-12 16:13:04 +02:00
parent 596788cc26
commit 84a0424789
12 changed files with 409 additions and 18 deletions
+10 -1
View File
@@ -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
@@ -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);
}
}
@@ -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<SessionAnnotation>();
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}'");
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class ListTenantSessionAnnotationsFunction
{
private readonly ILogger<ListTenantSessionAnnotationsFunction> _logger;
private readonly ISessionAnnotationRepository _annotationRepo;
public ListTenantSessionAnnotationsFunction(
ILogger<ListTenantSessionAnnotationsFunction> logger,
ISessionAnnotationRepository annotationRepo)
{
_logger = logger;
_annotationRepo = annotationRepo;
}
[Function("ListTenantSessionAnnotations")]
public async Task<HttpResponseData> 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;
}
}
}
}
@@ -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);
@@ -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),
@@ -34,6 +34,11 @@ namespace AutopilotMonitor.Shared.DataAccess
/// (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">
/// True for callers without global scope: adds a server-side <c>Lane ne 'globaladmin'</c>
/// clause so the platform-internal lane never consumes page budget (and an explicit
/// <paramref name="lane"/>=globaladmin filter simply yields an empty page).
/// </param>
Task<(List<SessionAnnotation> 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);
}
}
@@ -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<AnnotationRow[]>([]);
const [nextLink, setNextLink] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(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 (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="py-6 px-4 sm:px-6 lg:px-8">
<h1 className="text-2xl font-normal text-gray-900">Annotations</h1>
<p className="mt-1 text-sm text-gray-500">
Every session your team has judged — open one to read or update the verdict.
</p>
</div>
</header>
<main className="py-6 px-4 sm:px-6 lg:px-8">
<div className="bg-white shadow rounded-lg p-6">
<div className="flex flex-wrap items-center gap-3 mb-4">
<select
value={verdictFilter}
onChange={(e) => setVerdictFilter(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-green-500"
aria-label="Filter by verdict"
>
<option value="">All verdicts</option>
{ANNOTATION_VERDICTS.map((v) => (
<option key={v} value={v}>{VERDICT_LABELS[v]}</option>
))}
</select>
<select
value={laneFilter}
onChange={(e) => setLaneFilter(e.target.value)}
className="border border-gray-300 rounded-md px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-green-500"
aria-label="Filter by role"
>
<option value="">All roles</option>
{lanes.map((lane) => (
<option key={lane} value={lane}>{LANE_LABELS[lane]}</option>
))}
</select>
</div>
{loadError ? (
<p className="text-sm text-red-600">{loadError}</p>
) : rows.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>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead>
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-3 py-2">Verdict</th>
<th className="px-3 py-2">Role</th>
<th className="px-3 py-2">Note</th>
<th className="px-3 py-2">Author</th>
<th className="px-3 py-2">Updated</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{rows.map((row) => (
<tr key={`${row.sessionId}_${row.lane}`} className="hover:bg-gray-50">
<td className="px-3 py-2 whitespace-nowrap">
{row.verdict != null ? (
<span
title={VERDICT_DESCRIPTIONS[row.verdict as AnnotationVerdict]}
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VERDICT_PILL_CLASSES[row.verdict] ?? "bg-gray-100 text-gray-600"}`}
>
{VERDICT_LABELS[row.verdict as AnnotationVerdict] ?? row.verdict}
</span>
) : (
<span className="text-xs text-gray-400">note only</span>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap text-gray-700">
{LANE_LABELS[row.lane as AnnotationLane] ?? row.lane}
</td>
<td className="px-3 py-2 text-gray-700 max-w-md">
<span className="line-clamp-2">{row.note || "—"}</span>
</td>
<td className="px-3 py-2 whitespace-nowrap text-gray-500">
{row.authorDisplayName || "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-gray-500">
{row.updatedAtUtc ? new Date(row.updatedAtUtc).toLocaleString() : "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<Link
href={sessionUrl(row.sessionId, { hash: "section-annotations" })}
className="text-green-700 hover:text-green-800 underline underline-offset-2"
>
open session
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex items-center gap-3 mt-4">
{loading && <span className="text-sm text-gray-400">Loading…</span>}
{!loading && nextLink && (
<button
onClick={() => fetchPage(`${API_BASE_URL}${nextLink}`, true)}
className="px-4 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Load more
</button>
)}
</div>
</div>
</main>
</div>
</ProtectedRoute>
);
}
@@ -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<string, string> = {
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 ? (
<span
key={lane}
className={`px-2 py-0.5 rounded-full font-medium ${VERDICT_PILL[verdict] ?? "bg-gray-100 text-gray-600"}`}
className={`px-2 py-0.5 rounded-full font-medium ${VERDICT_PILL_CLASSES[verdict] ?? "bg-gray-100 text-gray-600"}`}
title={LANE_LABELS[lane]}
>
{VERDICT_LABELS[verdict as AnnotationVerdict] ?? verdict}
@@ -221,7 +215,7 @@ export default function SessionAnnotationsCard({
{annotation?.verdict != null && !writable && (
<span
title={VERDICT_DESCRIPTIONS[annotation.verdict as AnnotationVerdict]}
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VERDICT_PILL[annotation.verdict] ?? "bg-gray-100 text-gray-600"}`}
className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VERDICT_PILL_CLASSES[annotation.verdict] ?? "bg-gray-100 text-gray-600"}`}
>
{VERDICT_LABELS[annotation.verdict as AnnotationVerdict] ?? annotation.verdict}
</span>
@@ -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"
}`}
>
@@ -57,6 +57,14 @@ export const VERDICT_DESCRIPTIONS: Record<AnnotationVerdict, string> = {
inconclusive: "The investigation ended without a clear answer",
};
/** Shared verdict pill styling (session card + annotations overview page). */
export const VERDICT_PILL_CLASSES: Record<string, string> = {
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<AnnotationLane, string> = {
operator: "Operator",
tenantadmin: "Tenant Admin",
+20
View File
@@ -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 }) =>
@@ -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: <MonitorIcon /> },
{ id: "annotations", label: "Annotations", href: "/annotations", icon: <PencilSquareIcon /> },
{ id: "geographic-performance", label: "Geographic Perf.", href: "/geographic-performance", icon: <GlobeAltIcon /> },
{ id: "fleet-health", label: "Fleet Health", href: "/fleet-health", icon: <ChartBarIcon /> },
{ id: "sla", label: "SLA Compliance", href: "/sla", icon: <ShieldCheckIcon /> },