mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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}'");
|
||||
|
||||
+116
@@ -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",
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user