mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c770bbffcf | |||
| 3fbdc8ef62 | |||
| 35cea4fe64 | |||
| ade6c4948f | |||
| 83da522dd7 | |||
| c15394df87 | |||
| 810a6f1f2f | |||
| 019ac3959e | |||
| 9236aba46e | |||
| fda52666d6 | |||
| dcec6a1863 | |||
| 041626494e | |||
| fd32a90d57 | |||
| 90b8243894 |
@@ -77,12 +77,11 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Errors fail outright. Warnings are ratcheted: the cap is the current
|
||||
# count of accepted warnings (React-Compiler hooks-rule findings demoted
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
# Errors fail outright, and so does ANY warning: the codebase is
|
||||
# warning-clean and must stay that way — fix new findings, never
|
||||
# raise the cap.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 99
|
||||
run: npm run lint -- --max-warnings 0
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
@@ -114,7 +113,7 @@ jobs:
|
||||
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
|
||||
# is honored by vector-search-provider.ts.
|
||||
- name: Cache embedding model
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/.hf-cache
|
||||
key: hf-model-Xenova-all-MiniLM-L6-v2
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project>
|
||||
<!-- Warning freeze (2026-08-13, backlog cleared to zero): every .NET build in this repo
|
||||
must stay warning-free. Compiler, MSBuild and NuGet restore warnings are all fatal.
|
||||
Exception: NuGet audit advisories (NU1901-NU1904) stay warnings — a CVE published
|
||||
against an already-referenced package must not block an unrelated hotfix deploy;
|
||||
advisories are handled by bumping the package, not by failing the build. -->
|
||||
<PropertyGroup>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -42,7 +42,7 @@ Routes (all in `EndpointAccessPolicyCatalog`):
|
||||
| --- | --- | --- |
|
||||
| `GET sessions/{sessionId}/annotations` | `MemberRead` + QueryParam | handler filters the GA lane |
|
||||
| `PUT sessions/{sessionId}/annotations/{lane}` | `TenantAdminOrOperator` + QueryParam | re-gated per lane in-function |
|
||||
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream |
|
||||
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream; delegated ("MSP") readers are rescue-admitted on a managed `?tenantId=` only |
|
||||
|
||||
**Per-lane write matrix** (`UpsertSessionAnnotationFunction.IsLaneWritableByCaller`, an
|
||||
`internal static` pure function like `QueueSessionActionFunction.IsTypeAllowedForCaller`):
|
||||
@@ -55,7 +55,10 @@ GA writes exactly the `globaladmin` lane (the platform labeling flow).
|
||||
|
||||
**GA lane is platform-internal.** `GetSessionAnnotationsFunction.FilterLanesForCaller` drops the
|
||||
`globaladmin` lane for every caller without `HasGlobalScope` (tenant members and delegated
|
||||
admins). Tenant lanes are readable by all members of that tenant.
|
||||
admins). Both list endpoints enforce the same rule server-side via the repository's
|
||||
`excludeGlobalAdminLane` OData clause — on the global route this matters exactly for the
|
||||
delegated-rescue path, which reaches it without global scope. Tenant lanes are readable by all
|
||||
members of that tenant.
|
||||
|
||||
**Audit.** Non-GA writes/clears log `LogAuditEntryAsync(..., "SessionAnnotation",
|
||||
"{sessionId}/{lane}", ...)`; GA writes are skipped (platform-internal labeling must not surface
|
||||
@@ -82,12 +85,20 @@ in the tenant-visible audit log — same convention as GA session-report submiss
|
||||
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
|
||||
dilute both): tenant-scoped list backed by `GET sessions/annotations/list`
|
||||
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The path sits under the
|
||||
`/api/sessions` client-cert exclusion prefix (rename-over-exclusion convention; the Azure
|
||||
exclusion list is portal-only config) and uses THREE segments because a two-segment literal
|
||||
would be eaten by the `sessions/{sessionId}` route template. 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`.
|
||||
deep-link to `sessions?id=…#section-annotations`. The page carries the standard tenant
|
||||
switcher (`useAggregatedAdminScope` + `TenantScopeSelector`): a GA/Reader defaults to the
|
||||
"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`.
|
||||
* **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
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-13
|
||||
|
||||
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
|
||||
|
||||
## 2026-08-12
|
||||
|
||||
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
|
||||
|
||||
@@ -107,6 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(
|
||||
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
|
||||
entry.Timestamp);
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
/// </summary>
|
||||
public sealed class TenantIdWaitResolutionTests
|
||||
{
|
||||
private static AgentConfiguration Build(BootstrapConfigFile bootstrapConfig, params string[] args) =>
|
||||
private static AgentConfiguration Build(BootstrapConfigFile? bootstrapConfig, params string[] args) =>
|
||||
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
|
||||
args: args,
|
||||
tenantId: "tenant-t",
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Same compile context as the product project: with the shared framework referenced,
|
||||
NuGet prunes framework-provided transitive packages (e.g. the vulnerable
|
||||
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
|
||||
which would otherwise surface NU1903/NU1904 advisories here. -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
|
||||
@@ -92,6 +92,48 @@ public class SessionAnnotationFunctionsTests
|
||||
Assert.Equal(3, visible.Count);
|
||||
}
|
||||
|
||||
// ── global list: GA-lane exclusion follows global scope ─────────────────
|
||||
// The route is GlobalReadOrAdmin, but the delegated ("MSP") read rescue admits a
|
||||
// delegated caller on its managed ?tenantId= path — the platform-internal
|
||||
// globaladmin lane must be excluded server-side for anyone without global scope.
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
public async Task GlobalList_derives_globaladmin_lane_exclusion_from_global_scope(
|
||||
bool hasGlobalScope, bool expectedExclude)
|
||||
{
|
||||
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
|
||||
bool? capturedExclude = null;
|
||||
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)
|
||||
.ReturnsAsync((new List<SessionAnnotation>(), (string?)null));
|
||||
|
||||
var function = new ListSessionAnnotationsFunction(
|
||||
NullLogger<ListSessionAnnotationsFunction>.Instance, annotationRepo.Object);
|
||||
|
||||
var requestCtx = new RequestContext
|
||||
{
|
||||
TenantId = TenantId,
|
||||
TargetTenantId = TenantId,
|
||||
UserPrincipalName = "reader@contoso.com",
|
||||
IsGlobalAdmin = hasGlobalScope,
|
||||
};
|
||||
var req = BuildRequest(Principal(("tid", TenantId)), requestCtx, new { });
|
||||
Mock.Get(req).SetupGet(r => r.Url)
|
||||
.Returns(new Uri("https://localhost/api/global/session-annotations"));
|
||||
|
||||
var res = await function.Run(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
Assert.Equal(expectedExclude, capturedExclude);
|
||||
}
|
||||
|
||||
// ── endpoint-level tests (fake HttpRequestData, same harness as
|
||||
// GatherRulesFunctionAuthorEndpointTests) ──────────────────────────────
|
||||
|
||||
|
||||
@@ -55,12 +55,6 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<!-- Security pins: Microsoft.Identity.Web 4.3.0 pulls these transitively at 10.0.0, which carries
|
||||
known advisories (GHSA-9mv3-2cwr-p262 DataProtection; GHSA-37gx-xxp4-5rgx and siblings on
|
||||
Cryptography.Xml). Force the patched 10.0.10 servicing build. Remove once Identity.Web ships
|
||||
a dependency floor past the fix. -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.10" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
|
||||
<PackageReference Include="Resend" Version="0.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
+9
-2
@@ -13,7 +13,9 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Cross-tenant evaluation stream over all annotation lanes (the flywheel read:
|
||||
/// verdicts per rule, false-positive rates, labeled sessions). Platform scope only.
|
||||
/// verdicts per rule, false-positive rates, labeled sessions). Platform scope for the
|
||||
/// aggregate; a delegated ("MSP") reader reaches it only on its managed ?tenantId= path
|
||||
/// and never sees the platform-internal globaladmin lane.
|
||||
/// </summary>
|
||||
public class ListSessionAnnotationsFunction
|
||||
{
|
||||
@@ -35,6 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
@@ -63,9 +66,13 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
}
|
||||
}
|
||||
|
||||
// Not only GA/Reader reach this route: the delegated ("MSP") read rescue admits a
|
||||
// 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.DateFrom, parsed.DateTo, parsed.PageSize, azureToken);
|
||||
parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken,
|
||||
excludeGlobalAdminLane: !requestCtx.HasGlobalScope);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(nextRawToken))
|
||||
|
||||
+6
-1
@@ -31,9 +31,14 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
_annotationRepo = annotationRepo;
|
||||
}
|
||||
|
||||
// Route lives under the /api/sessions prefix so the platform cert-exclusion list
|
||||
// (Azure-portal-only config) needs no new entry — same convention as
|
||||
// /api/diagnostics/files. Three segments on purpose: a two-segment literal
|
||||
// ("sessions/annotations") would be eaten by the sibling "sessions/{sessionId}"
|
||||
// template (see the /api/stats/sessions incident note in lib/api.ts).
|
||||
[Function("ListTenantSessionAnnotations")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "session-annotations")] HttpRequestData req)
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/annotations/list")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -109,7 +109,8 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
}
|
||||
|
||||
public const string GlobalBasePath = "/api/global/session-annotations";
|
||||
public const string TenantBasePath = "/api/session-annotations";
|
||||
// Under /api/sessions so the platform client-cert exclusion prefix covers it.
|
||||
public const string TenantBasePath = "/api/sessions/annotations/list";
|
||||
|
||||
public static string BuildNextLink(Parsed parsed, string wireContinuation, string basePath = GlobalBasePath)
|
||||
{
|
||||
|
||||
@@ -237,9 +237,11 @@ public static class EndpointAccessPolicyCatalog
|
||||
// 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; the tenant list excludes the
|
||||
// lane server-side in the OData query).
|
||||
// lane server-side in the OData query). The list route deliberately lives under the
|
||||
// /api/sessions client-cert exclusion prefix, three segments deep (a two-segment
|
||||
// literal would be eaten by the sessions/{sessionId} template).
|
||||
new("GET", "sessions/{sessionId}/annotations", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "session-annotations", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "sessions/annotations/list", 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),
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<!-- netstandard2.0 for the net48 agent; net10.0 so nullable-enabled backend consumers
|
||||
compile against the real BCL nullability attributes instead of the internal
|
||||
netstandard2.0 polyfill (NullableAttributes.cs), which would otherwise collide
|
||||
with System.Runtime's public copy through InternalsVisibleTo (CS0433). -->
|
||||
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -10,10 +11,10 @@ namespace AutopilotMonitor.Shared.Logging
|
||||
public class CmTraceLogEntry
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string Component { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string Component { get; set; } = string.Empty;
|
||||
public int Type { get; set; } // 1=Info, 2=Warning, 3=Error
|
||||
public string Thread { get; set; }
|
||||
public string Thread { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -42,11 +43,12 @@ namespace AutopilotMonitor.Shared.Logging
|
||||
/// Attempts to parse a single line of CMTrace-formatted log.
|
||||
/// Returns true if parsing succeeded, false if the line doesn't match the format.
|
||||
/// </summary>
|
||||
public static bool TryParseLine(string line, out CmTraceLogEntry entry)
|
||||
public static bool TryParseLine(string? line, [NotNullWhen(true)] out CmTraceLogEntry? entry)
|
||||
{
|
||||
entry = null;
|
||||
|
||||
if (string.IsNullOrEmpty(line) || !line.StartsWith("<![LOG["))
|
||||
// netstandard2.0's IsNullOrEmpty lacks [NotNullWhen(false)] — assert non-null after it.
|
||||
if (string.IsNullOrEmpty(line) || !line!.StartsWith("<![LOG["))
|
||||
return false;
|
||||
|
||||
var match = CmTraceRegex.Match(line);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// netstandard2.0 ships none of the nullable-flow attributes, so shared Try-pattern APIs
|
||||
// could not tell nullable-enabled consumers (backend net10, agent test projects) that an
|
||||
// out param is non-null on success. Internal polyfill — the consuming compiler matches
|
||||
// these attributes by full name from metadata, so accessibility does not matter.
|
||||
#if NETSTANDARD2_0
|
||||
namespace System.Diagnostics.CodeAnalysis
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
|
||||
internal sealed class NotNullWhenAttribute : Attribute
|
||||
{
|
||||
public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
|
||||
|
||||
public bool ReturnValue { get; }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Services
|
||||
{
|
||||
@@ -28,10 +29,9 @@ namespace AutopilotMonitor.Shared.Services
|
||||
/// normalized base URL (<c>https://{lowercase-host}</c>, no trailing slash) on success.
|
||||
/// Rules: absolute https URI, default port, no userinfo/path/query/fragment, host
|
||||
/// matching <see cref="AllowedHostSuffixes"/>. <paramref name="normalized"/> is non-null
|
||||
/// exactly when this returns <c>true</c> (netstandard2.0 has no usable
|
||||
/// <c>[NotNullWhen]</c>, so callers must check the bool themselves).
|
||||
/// exactly when this returns <c>true</c>.
|
||||
/// </summary>
|
||||
public static bool TryNormalizeTarget(string candidate, out string? normalized)
|
||||
public static bool TryNormalizeTarget(string candidate, [NotNullWhen(true)] out string? normalized)
|
||||
{
|
||||
normalized = null;
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
import { BrandMark } from "../../components/BrandMark";
|
||||
|
||||
// The URL query string is fixed for the lifetime of a page load — nothing to subscribe to.
|
||||
const subscribeNever = () => () => {};
|
||||
|
||||
export default function ActivationPage() {
|
||||
const { isAuthenticated, isLoading, user, isActivationPending, activationMessage, logout, getAccessToken, refreshUserInfo } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -17,14 +20,17 @@ export default function ActivationPage() {
|
||||
|
||||
// ?demo=1 renders the page without auth/redirects (placeholder data) so
|
||||
// the design can be reviewed locally and on SWA preview environments.
|
||||
const [demo, setDemo] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
setDemo(new URLSearchParams(window.location.search).has("demo"));
|
||||
}, []);
|
||||
// The prerendered HTML uses the server snapshot (false); the client re-renders
|
||||
// with the real query flag — no hydration mismatch, no effect-phase setState.
|
||||
const demo = useSyncExternalStore(
|
||||
subscribeNever,
|
||||
() => new URLSearchParams(window.location.search).has("demo"),
|
||||
() => false,
|
||||
);
|
||||
|
||||
// If the tenant is activated (e.g. activated tenant navigates here), redirect away
|
||||
useEffect(() => {
|
||||
if (demo === null || demo) return;
|
||||
if (demo) return;
|
||||
if (!isLoading && isAuthenticated && user && !isActivationPending) {
|
||||
if (user.isTenantAdmin || user.isGlobalAdmin) {
|
||||
router.push("/dashboard");
|
||||
@@ -41,7 +47,7 @@ export default function ActivationPage() {
|
||||
// ~1 minute after signup. refreshUserInfo clears isActivationPending on a successful
|
||||
// auth/me, which triggers the redirect effect above — no manual reload needed.
|
||||
useEffect(() => {
|
||||
if (demo === null || demo) return;
|
||||
if (demo) return;
|
||||
if (isLoading || !isAuthenticated || !isActivationPending) return;
|
||||
const id = setInterval(() => {
|
||||
refreshUserInfo().catch(() => { /* transient — next tick retries */ });
|
||||
|
||||
@@ -60,7 +60,10 @@ function BackupDetailContent() {
|
||||
}, [backupId, getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const run = async () => {
|
||||
await load();
|
||||
};
|
||||
void run();
|
||||
}, [load]);
|
||||
|
||||
const openRestoreModal = async (tableName: string, pk: string, rk: string) => {
|
||||
@@ -230,19 +233,18 @@ function RestoreInputRow({
|
||||
const [pk, setPk] = useState<string>("");
|
||||
const [rk, setRk] = useState<string>("");
|
||||
|
||||
// Keep the dropdown in sync if the manifest reload changes the restorable set.
|
||||
useEffect(() => {
|
||||
if (!restorable.find((t) => t.tableName === tableName)) {
|
||||
setTableName(restorable[0]?.tableName ?? "");
|
||||
}
|
||||
}, [restorable, tableName]);
|
||||
// The user's pick can go stale when a manifest reload changes the restorable
|
||||
// set — fall back to the first restorable table instead of syncing state.
|
||||
const effectiveTableName = restorable.some((t) => t.tableName === tableName)
|
||||
? tableName
|
||||
: (restorable[0]?.tableName ?? "");
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1 text-xs text-gray-700 dark:text-gray-300">
|
||||
Table
|
||||
<select
|
||||
value={tableName}
|
||||
value={effectiveTableName}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-sm font-mono"
|
||||
>
|
||||
@@ -271,11 +273,11 @@ function RestoreInputRow({
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmit(tableName, pk, rk)}
|
||||
onClick={() => onSubmit(effectiveTableName, pk, rk)}
|
||||
// Backend contract (RestoreTablePreflightValidator) allows empty PK/RK
|
||||
// — only null is rejected. Match that here so an Azure Table row with
|
||||
// a literal empty key (legal per spec) can be previewed/restored.
|
||||
disabled={disabled || !tableName}
|
||||
disabled={disabled || !effectiveTableName}
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white text-sm rounded-md transition-colors"
|
||||
>
|
||||
Preview restore
|
||||
|
||||
@@ -44,7 +44,10 @@ export default function BackupsListPage() {
|
||||
}, [getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const run = async () => {
|
||||
await load();
|
||||
};
|
||||
void run();
|
||||
}, [load]);
|
||||
|
||||
// Poll the active job until terminal, then refresh the list.
|
||||
@@ -57,7 +60,10 @@ export default function BackupsListPage() {
|
||||
activeJob.state === "BlockedTerminal"
|
||||
) {
|
||||
// Terminal — final refresh and stop polling.
|
||||
void load();
|
||||
const run = async () => {
|
||||
await load();
|
||||
};
|
||||
void run();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -135,10 +135,13 @@ function DeviceBlockSectionInner({
|
||||
const reasonParam = searchParams?.get("reason");
|
||||
if (!sessionIdParam) return;
|
||||
autoResolvedRef.current = true;
|
||||
setBlockSessionId(sessionIdParam);
|
||||
if (actionParam === "Block" || actionParam === "Kill") setBlockAction(actionParam);
|
||||
if (reasonParam) setBlockReason(reasonParam);
|
||||
void handleResolveSession(sessionIdParam);
|
||||
const run = async () => {
|
||||
setBlockSessionId(sessionIdParam);
|
||||
if (actionParam === "Block" || actionParam === "Kill") setBlockAction(actionParam);
|
||||
if (reasonParam) setBlockReason(reasonParam);
|
||||
await handleResolveSession(sessionIdParam);
|
||||
};
|
||||
void run();
|
||||
// handleResolveSession intentionally excluded from deps — fires once per mount based on URL params
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
@@ -168,7 +168,10 @@ export function DistressReportsSection({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
const run = async () => {
|
||||
await fetchReports();
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -74,7 +74,10 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
|
||||
}, [getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback();
|
||||
const run = async () => {
|
||||
await fetchFeedback();
|
||||
};
|
||||
void run();
|
||||
}, [fetchFeedback]);
|
||||
|
||||
// Partition by tab. Server returns mixed; client splits + filters per active tab so the
|
||||
|
||||
@@ -56,19 +56,13 @@ export function IgnoredSoftwareTab({
|
||||
}
|
||||
}, [getAccessToken, setError, onCountChanged]);
|
||||
|
||||
// Lazy load on mount
|
||||
// Fetch on mount and refetch when refreshTrigger changes (e.g. unmapped tab ignored an item)
|
||||
useEffect(() => {
|
||||
if (!ignoredLoaded) {
|
||||
fetchIgnoredSoftware();
|
||||
}
|
||||
const run = async () => {
|
||||
await fetchIgnoredSoftware();
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ignoredLoaded]);
|
||||
|
||||
// Reset when refreshTrigger changes (e.g. unmapped tab ignored an item)
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
setIgnoredLoaded(false);
|
||||
}
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
@@ -83,7 +83,10 @@ export function MappedSoftwareTab({
|
||||
// Lazy load on first render
|
||||
useEffect(() => {
|
||||
if (!mappedLoaded) {
|
||||
fetchCpeMappings();
|
||||
const run = async () => {
|
||||
await fetchCpeMappings();
|
||||
};
|
||||
void run();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mappedLoaded]);
|
||||
@@ -101,6 +104,14 @@ export function MappedSoftwareTab({
|
||||
const getMappedRowKey = (entry: CpeMappingEntry): string =>
|
||||
`${entry.source}::${entry.normalizedVendor}::${entry.normalizedProduct}`;
|
||||
|
||||
// Jump back to page 0 when the search changes (adjust-during-render pattern,
|
||||
// see react.dev "storing information from previous renders").
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
if (prevSearchQuery !== searchQuery) {
|
||||
setPrevSearchQuery(searchQuery);
|
||||
setMappedPage(0);
|
||||
}
|
||||
|
||||
const filteredMappedEntries = mappedEntries.filter((m) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
@@ -123,11 +134,6 @@ export function MappedSoftwareTab({
|
||||
(mappedPage + 1) * pageSize
|
||||
);
|
||||
|
||||
// Reset page when search changes
|
||||
useEffect(() => {
|
||||
setMappedPage(0);
|
||||
}, [searchQuery]);
|
||||
|
||||
const getSourceBadge = (source: string) => {
|
||||
switch (source) {
|
||||
case "custom":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { type OpsAlertRule } from "../AdminConfigContext";
|
||||
import { AUTO_ACTION_MODES, describeAutoActionWarning, type AutoActionMode } from "./excessiveEventAutoAction";
|
||||
|
||||
@@ -146,6 +146,19 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
Platform: "text-sky-700 dark:text-sky-300",
|
||||
};
|
||||
|
||||
// Full rule list: merge saved rules with all known event types
|
||||
function buildFullRules(savedRules: OpsAlertRule[]): OpsAlertRule[] {
|
||||
const ruleMap = new Map(savedRules.map(r => [r.eventType, r]));
|
||||
const fullRules: OpsAlertRule[] = [];
|
||||
for (const eventTypes of Object.values(OPS_EVENT_TYPES)) {
|
||||
for (const et of eventTypes) {
|
||||
const existing = ruleMap.get(et);
|
||||
fullRules.push(existing ?? { eventType: et, minSeverity: "Error", enabled: false });
|
||||
}
|
||||
}
|
||||
return fullRules;
|
||||
}
|
||||
|
||||
interface OpsAlertRulesSectionProps {
|
||||
loadingConfig: boolean;
|
||||
savingOpsAlerts: boolean;
|
||||
@@ -190,31 +203,27 @@ export function OpsAlertRulesSection({
|
||||
excessiveEventAutoActionDurationHours: autoActionDurationProp,
|
||||
onSave,
|
||||
}: OpsAlertRulesSectionProps) {
|
||||
// Local state for editing
|
||||
const [rules, setRules] = useState<OpsAlertRule[]>([]);
|
||||
const [telegramEnabled, setTelegramEnabled] = useState(false);
|
||||
const [telegramChatId, setTelegramChatId] = useState("");
|
||||
const [teamsEnabled, setTeamsEnabled] = useState(false);
|
||||
const [teamsWebhookUrl, setTeamsWebhookUrl] = useState("");
|
||||
const [slackEnabled, setSlackEnabled] = useState(false);
|
||||
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
|
||||
// Local state for editing, seeded from props
|
||||
const [rules, setRules] = useState<OpsAlertRule[]>(() => buildFullRules(opsAlertRules));
|
||||
const [telegramEnabled, setTelegramEnabled] = useState(opsAlertTelegramEnabled);
|
||||
const [telegramChatId, setTelegramChatId] = useState(opsAlertTelegramChatId);
|
||||
const [teamsEnabled, setTeamsEnabled] = useState(opsAlertTeamsEnabled);
|
||||
const [teamsWebhookUrl, setTeamsWebhookUrl] = useState(opsAlertTeamsWebhookUrl);
|
||||
const [slackEnabled, setSlackEnabled] = useState(opsAlertSlackEnabled);
|
||||
const [slackWebhookUrl, setSlackWebhookUrl] = useState(opsAlertSlackWebhookUrl);
|
||||
const [excessiveThreshold, setExcessiveThreshold] = useState(excessiveEventCountThresholdProp);
|
||||
const [autoActionMode, setAutoActionMode] = useState<AutoActionMode>(autoActionModeProp);
|
||||
const [autoActionThreshold, setAutoActionThreshold] = useState(autoActionThresholdProp);
|
||||
const [autoActionDuration, setAutoActionDuration] = useState(autoActionDurationProp);
|
||||
|
||||
// Sync from props
|
||||
useEffect(() => {
|
||||
// Build full rule list: merge saved rules with all known event types
|
||||
const ruleMap = new Map(opsAlertRules.map(r => [r.eventType, r]));
|
||||
const fullRules: OpsAlertRule[] = [];
|
||||
for (const eventTypes of Object.values(OPS_EVENT_TYPES)) {
|
||||
for (const et of eventTypes) {
|
||||
const existing = ruleMap.get(et);
|
||||
fullRules.push(existing ?? { eventType: et, minSeverity: "Error", enabled: false });
|
||||
}
|
||||
}
|
||||
setRules(fullRules);
|
||||
// Re-seed the local draft whenever a synced prop changes (config load/save),
|
||||
// replicating the previous sync effect's dependency-array comparison via the
|
||||
// adjust-during-render pattern (react.dev "storing information from previous renders").
|
||||
const syncedProps = [opsAlertRules, opsAlertTelegramEnabled, opsAlertTelegramChatId, opsAlertTeamsEnabled, opsAlertTeamsWebhookUrl, opsAlertSlackEnabled, opsAlertSlackWebhookUrl, excessiveEventCountThresholdProp, autoActionModeProp, autoActionThresholdProp, autoActionDurationProp];
|
||||
const [prevSyncedProps, setPrevSyncedProps] = useState(syncedProps);
|
||||
if (syncedProps.some((value, i) => !Object.is(value, prevSyncedProps[i]))) {
|
||||
setPrevSyncedProps(syncedProps);
|
||||
setRules(buildFullRules(opsAlertRules));
|
||||
setTelegramEnabled(opsAlertTelegramEnabled);
|
||||
setTelegramChatId(opsAlertTelegramChatId);
|
||||
setTeamsEnabled(opsAlertTeamsEnabled);
|
||||
@@ -225,7 +234,7 @@ export function OpsAlertRulesSection({
|
||||
setAutoActionMode(autoActionModeProp);
|
||||
setAutoActionThreshold(autoActionThresholdProp);
|
||||
setAutoActionDuration(autoActionDurationProp);
|
||||
}, [opsAlertRules, opsAlertTelegramEnabled, opsAlertTelegramChatId, opsAlertTeamsEnabled, opsAlertTeamsWebhookUrl, opsAlertSlackEnabled, opsAlertSlackWebhookUrl, excessiveEventCountThresholdProp, autoActionModeProp, autoActionThresholdProp, autoActionDurationProp]);
|
||||
}
|
||||
|
||||
const toggleRule = (eventType: string) => {
|
||||
setRules(prev => prev.map(r => r.eventType === eventType ? { ...r, enabled: !r.enabled } : r));
|
||||
|
||||
@@ -179,10 +179,13 @@ export function OpsEventsSection({
|
||||
// re-fire after every successful page-N click, race a page-1 fetch against
|
||||
// it, and snap the user back to page 1.
|
||||
useEffect(() => {
|
||||
setContinuation(null);
|
||||
setContinuationStack([]);
|
||||
setPageNumber(1);
|
||||
fetchEvents(null);
|
||||
const run = async () => {
|
||||
setContinuation(null);
|
||||
setContinuationStack([]);
|
||||
setPageNumber(1);
|
||||
await fetchEvents(null);
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [categoryFilter, dateFromIso, dateToIso]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
@@ -77,6 +77,113 @@ function CopyButton({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AdminNoteEditor({
|
||||
report,
|
||||
canMutate,
|
||||
getAccessToken,
|
||||
onSaved,
|
||||
}: {
|
||||
report: SessionReport;
|
||||
canMutate: boolean;
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
onSaved: (updated: SessionReport) => void;
|
||||
}) {
|
||||
const [adminNoteValue, setAdminNoteValue] = useState(report.adminNote ?? "");
|
||||
const [savingNote, setSavingNote] = useState(false);
|
||||
const [noteSaveResult, setNoteSaveResult] = useState<"saved" | string | null>(null);
|
||||
|
||||
// The parent swaps in a fresh report object after a successful save; mirror the
|
||||
// previous reset-on-selection semantics for that same-report identity change
|
||||
// (cross-report switches remount this editor via key={reportId}). Adjust-during-
|
||||
// render pattern, see react.dev "storing information from previous renders".
|
||||
const [prevReport, setPrevReport] = useState(report);
|
||||
if (prevReport !== report) {
|
||||
setPrevReport(report);
|
||||
setAdminNoteValue(report.adminNote ?? "");
|
||||
setNoteSaveResult(null);
|
||||
}
|
||||
|
||||
const handleSaveAdminNote = async () => {
|
||||
try {
|
||||
setSavingNote(true);
|
||||
setNoteSaveResult(null);
|
||||
|
||||
const res = await authenticatedFetch(
|
||||
api.reports.note(report.reportId),
|
||||
getAccessToken,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ adminNote: adminNoteValue }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to save note: ${res.statusText}`);
|
||||
|
||||
// Update parent state
|
||||
onSaved({ ...report, adminNote: adminNoteValue });
|
||||
setNoteSaveResult("saved");
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while saving admin note");
|
||||
}
|
||||
setNoteSaveResult(err instanceof Error ? err.message : "Failed to save note");
|
||||
} finally {
|
||||
setSavingNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
Admin Note
|
||||
</dt>
|
||||
<dd className="mt-1.5">
|
||||
<textarea
|
||||
value={adminNoteValue}
|
||||
onChange={e => { setAdminNoteValue(e.target.value); setNoteSaveResult(null); }}
|
||||
readOnly={!canMutate}
|
||||
rows={3}
|
||||
placeholder={canMutate ? "Add an internal note about this report..." : "No note"}
|
||||
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<div className="text-xs">
|
||||
{noteSaveResult === "saved" && (
|
||||
<span className="text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{noteSaveResult && noteSaveResult !== "saved" && (
|
||||
<span className="text-red-600 dark:text-red-400">{noteSaveResult}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveAdminNote}
|
||||
disabled={!canMutate || savingNote}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
|
||||
>
|
||||
{savingNote ? (
|
||||
<>
|
||||
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Saving...
|
||||
</>
|
||||
) : "Save Note"}
|
||||
</button>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionReportsSection(props: SessionReportsSectionProps) {
|
||||
// useSearchParams() in the inner component requires a Suspense boundary for
|
||||
// static prerender (this section renders inside the prerendered
|
||||
@@ -101,9 +208,6 @@ function SessionReportsSectionInner({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
|
||||
const [downloadingBlob, setDownloadingBlob] = useState<string | null>(null);
|
||||
const [adminNoteValue, setAdminNoteValue] = useState("");
|
||||
const [savingNote, setSavingNote] = useState(false);
|
||||
const [noteSaveResult, setNoteSaveResult] = useState<"saved" | string | null>(null);
|
||||
|
||||
// Pattern B1 click-next replace state — backend pagination
|
||||
const [tenantFilterInput, setTenantFilterInput] = useState("");
|
||||
@@ -114,30 +218,34 @@ function SessionReportsSectionInner({
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
|
||||
// Deep link from GA notifications: ?reportId=… auto-opens the report modal once
|
||||
// the first page has loaded. Reports are listed newest-first, so a report reached
|
||||
// via a fresh notification is on page 1; older ones get a hint instead.
|
||||
// the first page has loaded (one-shot). Reports are listed newest-first, so a
|
||||
// report reached via a fresh notification is on page 1; older ones get a hint
|
||||
// instead. Adjust-during-render (react.dev "storing information from previous
|
||||
// renders"); the miss branch only records the id here because the hint lives in
|
||||
// PARENT state, which must not be set during this component's render — the
|
||||
// effect below routes it to setError.
|
||||
const searchParams = useSearchParams();
|
||||
const autoOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoOpenedRef.current || loading) return;
|
||||
const reportIdParam = searchParams?.get("reportId");
|
||||
if (!reportIdParam) return;
|
||||
autoOpenedRef.current = true;
|
||||
const reportIdParam = searchParams?.get("reportId") ?? null;
|
||||
const [autoOpenConsumed, setAutoOpenConsumed] = useState(false);
|
||||
const [autoOpenMissId, setAutoOpenMissId] = useState<string | null>(null);
|
||||
if (!autoOpenConsumed && !loading && reportIdParam) {
|
||||
setAutoOpenConsumed(true);
|
||||
const match = reports.find((r) => r.reportId === reportIdParam);
|
||||
if (match) {
|
||||
setSelectedReport(match);
|
||||
} else {
|
||||
setError(`Report ${reportIdParam} is not on the first page — it may be older. Page through or filter by tenant to find it.`);
|
||||
setAutoOpenMissId(reportIdParam);
|
||||
}
|
||||
}, [loading, reports, searchParams, setError]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedReport) {
|
||||
setAdminNoteValue(selectedReport.adminNote ?? "");
|
||||
setNoteSaveResult(null);
|
||||
}
|
||||
}, [selectedReport]);
|
||||
if (autoOpenMissId === null) return;
|
||||
setError(`Report ${autoOpenMissId} is not on the first page — it may be older. Page through or filter by tenant to find it.`);
|
||||
// setError intentionally excluded from deps: the hint fires once per consumed
|
||||
// deep-link miss (autoOpenMissId is set at most once) and must not re-surface
|
||||
// after dismissal just because the parent's setError identity churns.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoOpenMissId]);
|
||||
|
||||
const handleDownload = async (blobName: string) => {
|
||||
try {
|
||||
@@ -166,36 +274,9 @@ function SessionReportsSectionInner({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAdminNote = async () => {
|
||||
if (!selectedReport) return;
|
||||
try {
|
||||
setSavingNote(true);
|
||||
setNoteSaveResult(null);
|
||||
|
||||
const res = await authenticatedFetch(
|
||||
api.reports.note(selectedReport.reportId),
|
||||
getAccessToken,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ adminNote: adminNoteValue }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to save note: ${res.statusText}`);
|
||||
|
||||
// Update local state
|
||||
const updated = { ...selectedReport, adminNote: adminNoteValue };
|
||||
setSelectedReport(updated);
|
||||
setReports(prev => prev.map(r => r.reportId === selectedReport.reportId ? updated : r));
|
||||
setNoteSaveResult("saved");
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while saving admin note");
|
||||
}
|
||||
setNoteSaveResult(err instanceof Error ? err.message : "Failed to save note");
|
||||
} finally {
|
||||
setSavingNote(false);
|
||||
}
|
||||
const handleNoteSaved = (updated: SessionReport) => {
|
||||
setSelectedReport(updated);
|
||||
setReports(prev => prev.map(r => r.reportId === updated.reportId ? updated : r));
|
||||
};
|
||||
|
||||
const fetchReports = useCallback(async (cursor: string | null, filterTenantId: string | undefined) => {
|
||||
@@ -238,10 +319,13 @@ function SessionReportsSectionInner({
|
||||
// re-fire after every successful page-N click, race a page-1 fetch against
|
||||
// it, and snap the user back to page 1.
|
||||
useEffect(() => {
|
||||
setContinuation(null);
|
||||
setContinuationStack([]);
|
||||
setPageNumber(1);
|
||||
fetchReports(null, tenantFilterApplied);
|
||||
const run = async () => {
|
||||
setContinuation(null);
|
||||
setContinuationStack([]);
|
||||
setPageNumber(1);
|
||||
await fetchReports(null, tenantFilterApplied);
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantFilterApplied]);
|
||||
|
||||
@@ -530,55 +614,15 @@ function SessionReportsSectionInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Note */}
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||
<dt className="font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
Admin Note
|
||||
</dt>
|
||||
<dd className="mt-1.5">
|
||||
<textarea
|
||||
value={adminNoteValue}
|
||||
onChange={e => { setAdminNoteValue(e.target.value); setNoteSaveResult(null); }}
|
||||
readOnly={!canMutate}
|
||||
rows={3}
|
||||
placeholder={canMutate ? "Add an internal note about this report..." : "No note"}
|
||||
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<div className="text-xs">
|
||||
{noteSaveResult === "saved" && (
|
||||
<span className="text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{noteSaveResult && noteSaveResult !== "saved" && (
|
||||
<span className="text-red-600 dark:text-red-400">{noteSaveResult}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveAdminNote}
|
||||
disabled={!canMutate || savingNote}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
|
||||
>
|
||||
{savingNote ? (
|
||||
<>
|
||||
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Saving...
|
||||
</>
|
||||
) : "Save Note"}
|
||||
</button>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
{/* Admin Note — keyed on the report so switching reports remounts
|
||||
the editor and re-seeds its draft from the report's saved note. */}
|
||||
<AdminNoteEditor
|
||||
key={selectedReport.reportId}
|
||||
report={selectedReport}
|
||||
canMutate={canMutate}
|
||||
getAccessToken={getAccessToken}
|
||||
onSaved={handleNoteSaved}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
|
||||
@@ -68,7 +68,10 @@ export function TenantAdminSection({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenantAdmins(tenantId);
|
||||
const run = async () => {
|
||||
await fetchTenantAdmins(tenantId);
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantId]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
@@ -104,22 +104,21 @@ function TenantManagementSectionInner({
|
||||
}: TenantManagementSectionProps) {
|
||||
// Read-only Global Readers may view tenants (incl. config report) but not edit them.
|
||||
const canMutate = useCanMutatePlatform();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Deep link from GA notifications: ?tenantId=… seeds the search box once, so the
|
||||
// list opens filtered to the tenant the notification is about. Seeded via the
|
||||
// useState initializer when the param is present at mount; the adjust-during-render
|
||||
// block below covers a param that only appears on a later soft navigation.
|
||||
const searchParams = useSearchParams();
|
||||
const tenantIdParam = searchParams?.get("tenantId") ?? null;
|
||||
const [searchQuery, setSearchQuery] = useState(() => tenantIdParam ?? "");
|
||||
const [seededSearch, setSeededSearch] = useState(tenantIdParam !== null);
|
||||
if (!seededSearch && tenantIdParam) {
|
||||
setSeededSearch(true);
|
||||
setSearchQuery(tenantIdParam);
|
||||
}
|
||||
// Mount-time clock for trial-expiry checks — render must stay pure
|
||||
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
|
||||
// Deep link from GA notifications: ?tenantId=… seeds the search box once, so the
|
||||
// list opens filtered to the tenant the notification is about.
|
||||
const searchParams = useSearchParams();
|
||||
const seededSearchRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (seededSearchRef.current) return;
|
||||
const tenantIdParam = searchParams?.get("tenantId");
|
||||
if (!tenantIdParam) return;
|
||||
seededSearchRef.current = true;
|
||||
setSearchQuery(tenantIdParam);
|
||||
}, [searchParams]);
|
||||
const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false);
|
||||
const [showOnlyReady, setShowOnlyReady] = useState(false);
|
||||
const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false);
|
||||
@@ -174,10 +173,13 @@ function TenantManagementSectionInner({
|
||||
const endIndex = startIndex + tenantsPerPage;
|
||||
const paginatedTenants = filteredTenants.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to first page when search changes
|
||||
useEffect(() => {
|
||||
// Reset to first page when the search or a filter changes (adjust-during-render
|
||||
// pattern, see react.dev "storing information from previous renders").
|
||||
const [prevFilterKey, setPrevFilterKey] = useState<[string, boolean, boolean]>([searchQuery, showOnlyWaitlist, showOnlyReady]);
|
||||
if (prevFilterKey[0] !== searchQuery || prevFilterKey[1] !== showOnlyWaitlist || prevFilterKey[2] !== showOnlyReady) {
|
||||
setPrevFilterKey([searchQuery, showOnlyWaitlist, showOnlyReady]);
|
||||
setCurrentPage(0);
|
||||
}, [searchQuery, showOnlyWaitlist, showOnlyReady]);
|
||||
}
|
||||
|
||||
const handleSaveTenant = async (tenant: TenantConfiguration) => {
|
||||
if (!canMutate) return; // read-only Global Reader
|
||||
|
||||
@@ -112,17 +112,23 @@ export function UnmappedSoftwareTab({
|
||||
|
||||
// Load on mount + whenever the page index changes
|
||||
useEffect(() => {
|
||||
fetchPage(currentPage);
|
||||
const run = async () => {
|
||||
await fetchPage(currentPage);
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// Refresh when parent signals (e.g. item restored from ignored tab) — reset to page 0
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
setSavedMappings(new Set());
|
||||
setSelectedEntries(new Map());
|
||||
if (currentPage !== 0) setCurrentPage(0);
|
||||
else fetchPage(0);
|
||||
const run = async () => {
|
||||
setSavedMappings(new Set());
|
||||
setSelectedEntries(new Map());
|
||||
if (currentPage !== 0) setCurrentPage(0);
|
||||
else await fetchPage(0);
|
||||
};
|
||||
void run();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshTrigger]);
|
||||
|
||||
@@ -96,7 +96,10 @@ export function VulnerabilityDataSection({
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSyncStatus();
|
||||
const run = async () => {
|
||||
await refreshSyncStatus();
|
||||
};
|
||||
void run();
|
||||
}, [refreshSyncStatus]);
|
||||
|
||||
const refreshSummary = useCallback(async () => {
|
||||
@@ -120,7 +123,10 @@ export function VulnerabilityDataSection({
|
||||
}, [getAccessToken, summaryDays]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSummary();
|
||||
const run = async () => {
|
||||
await refreshSummary();
|
||||
};
|
||||
void run();
|
||||
}, [refreshSummary]);
|
||||
|
||||
// Sync local state when adminConfig changes (initial load)
|
||||
|
||||
+9
-9
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
open: boolean;
|
||||
@@ -19,8 +19,14 @@ const CONFIRM_WORD = "DELETE";
|
||||
* (tenant id, table/row, count) and require a typed "DELETE" before the
|
||||
* primary button activates.
|
||||
*/
|
||||
export function DeleteConfirmModal({
|
||||
open,
|
||||
export function DeleteConfirmModal(props: DeleteConfirmModalProps) {
|
||||
// The dialog content only mounts while open, so the typed confirmation text
|
||||
// resets naturally on close (unmount) — no state sync needed.
|
||||
if (!props.open) return null;
|
||||
return <DeleteConfirmModalContent {...props} />;
|
||||
}
|
||||
|
||||
function DeleteConfirmModalContent({
|
||||
title,
|
||||
description,
|
||||
busy = false,
|
||||
@@ -29,12 +35,6 @@ export function DeleteConfirmModal({
|
||||
}: DeleteConfirmModalProps) {
|
||||
const [text, setText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setText("");
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canConfirm = text === CONFIRM_WORD && !busy;
|
||||
|
||||
return (
|
||||
|
||||
@@ -58,7 +58,10 @@ function CustomsArchiveDetailContent() {
|
||||
}, [tenantId, historyRowKey, getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const run = async () => {
|
||||
await load();
|
||||
};
|
||||
void run();
|
||||
}, [load]);
|
||||
|
||||
const toggleExpand = useCallback(async (entry: CustomsArchiveEntrySummary) => {
|
||||
|
||||
@@ -43,7 +43,10 @@ export default function CustomsArchivePage() {
|
||||
}, [filter, getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const run = async () => {
|
||||
await load();
|
||||
};
|
||||
void run();
|
||||
}, [load]);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
|
||||
@@ -225,7 +225,10 @@ export function SectionAgentMetrics() {
|
||||
// accounts refresh which would otherwise re-fire the effect after each
|
||||
// request and ping-pong the loading spinner.
|
||||
useEffect(() => {
|
||||
fetchMetrics(sampleSize, windowDays);
|
||||
const run = async () => {
|
||||
await fetchMetrics(sampleSize, windowDays);
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sampleSize, windowDays]);
|
||||
|
||||
|
||||
@@ -90,7 +90,10 @@ export function SectionMcpUsage() {
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(dateRange);
|
||||
const run = async () => {
|
||||
await fetchData(dateRange);
|
||||
};
|
||||
void run();
|
||||
}, [fetchData, dateRange]);
|
||||
|
||||
// Aggregate by user
|
||||
|
||||
@@ -156,7 +156,10 @@ export function SectionPlatformUsage() {
|
||||
useEffect(() => {
|
||||
// Fetch metrics on mount
|
||||
// Authorization is handled by admin layout ProtectedRoute
|
||||
fetchMetrics();
|
||||
const run = async () => {
|
||||
await fetchMetrics();
|
||||
};
|
||||
void run();
|
||||
}, [fetchMetrics]);
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
|
||||
+3
-3
@@ -62,10 +62,10 @@ export function DeletionManifestSummaryView({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSummary(null);
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSummary(null);
|
||||
try {
|
||||
const resp = await authenticatedFetch(
|
||||
api.sessionDeletions.storedManifest(sessionId, tenantId, manifestId, "summary"),
|
||||
|
||||
+4
-1
@@ -120,7 +120,10 @@ export function MaintenanceStatusBanner({
|
||||
}, [getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatus();
|
||||
const run = async () => {
|
||||
await fetchStatus();
|
||||
};
|
||||
void run();
|
||||
}, [fetchStatus, refreshKey]);
|
||||
|
||||
const triggerRun = useCallback(async () => {
|
||||
|
||||
+15
-14
@@ -85,8 +85,8 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
|
||||
if (!onlyWithRestore || tenantsWithRestore !== null) return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
setLoadingRestoreFilter(true);
|
||||
(async () => {
|
||||
setLoadingRestoreFilter(true);
|
||||
try {
|
||||
const resp = await authenticatedFetch(
|
||||
api.sessionDeletions.tenantsWithManifests(),
|
||||
@@ -142,23 +142,24 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
|
||||
// the operator never sees stale manifests / preview / restore-action that belong to the
|
||||
// previous tenant. Codex P2.
|
||||
useEffect(() => {
|
||||
// Immediate clear: dropping the displayed data on switch means the operator never has a
|
||||
// "this manifest from tenant A is selected while the dropdown says tenant B" moment.
|
||||
setSessions(null);
|
||||
setExpandedSessions(new Set());
|
||||
setSelection(null);
|
||||
setLoadedTenantId("");
|
||||
|
||||
if (!selectedTenantId) {
|
||||
setLoadingSessions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
setLoadingSessions(true);
|
||||
|
||||
(async () => {
|
||||
// Immediate clear: dropping the displayed data on switch means the operator never has a
|
||||
// "this manifest from tenant A is selected while the dropdown says tenant B" moment.
|
||||
setSessions(null);
|
||||
setExpandedSessions(new Set());
|
||||
setSelection(null);
|
||||
setLoadedTenantId("");
|
||||
|
||||
if (!selectedTenantId) {
|
||||
setLoadingSessions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSessions(true);
|
||||
|
||||
try {
|
||||
const resp = await authenticatedFetch(
|
||||
api.sessionDeletions.tenantManifests(selectedTenantId),
|
||||
|
||||
@@ -176,7 +176,10 @@ function InFlightTab({
|
||||
}, [getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchAll();
|
||||
const run = async () => {
|
||||
await fetchAll();
|
||||
};
|
||||
void run();
|
||||
}, [fetchAll, refreshKey]);
|
||||
|
||||
return (
|
||||
@@ -253,7 +256,10 @@ function PoisonedTab({
|
||||
}, [getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPoisoned();
|
||||
const run = async () => {
|
||||
await fetchPoisoned();
|
||||
};
|
||||
void run();
|
||||
}, [fetchPoisoned, refreshKey]);
|
||||
|
||||
return (
|
||||
@@ -353,7 +359,10 @@ function StrandedTab({
|
||||
}, [getAccessToken, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStranded();
|
||||
const run = async () => {
|
||||
await fetchStranded();
|
||||
};
|
||||
void run();
|
||||
}, [fetchStranded, refreshKey]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -92,7 +92,10 @@ export default function PresencePage() {
|
||||
|
||||
// Initial load + whenever the window changes.
|
||||
useEffect(() => {
|
||||
fetchPresence();
|
||||
const run = async () => {
|
||||
await fetchPresence();
|
||||
};
|
||||
void run();
|
||||
}, [fetchPresence, windowMinutes]);
|
||||
|
||||
// Auto-refresh on a fixed interval (reads the current window via ref).
|
||||
|
||||
+4
-1
@@ -80,7 +80,10 @@ export function SectionDelegatedAdmins() {
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssignments();
|
||||
const run = async () => {
|
||||
await fetchAssignments();
|
||||
};
|
||||
void run();
|
||||
}, [fetchAssignments]);
|
||||
|
||||
const handleGrant = useCallback(async () => {
|
||||
|
||||
@@ -89,7 +89,10 @@ export function SectionTenantGroups() {
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
const run = async () => {
|
||||
await fetchGroups();
|
||||
};
|
||||
void run();
|
||||
}, [fetchGroups]);
|
||||
|
||||
/** Runs a mutation with shared busy/error/refetch handling. `body` undefined ⇒ no JSON body. */
|
||||
|
||||
@@ -57,7 +57,10 @@ export function SectionUsagePlans() {
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTiers();
|
||||
const run = async () => {
|
||||
await fetchTiers();
|
||||
};
|
||||
void run();
|
||||
}, [fetchTiers]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
|
||||
+4
-1
@@ -406,7 +406,10 @@ export function SectionTenantConfigReport() {
|
||||
}, [selectedTenantId, getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
const run = async () => {
|
||||
await fetchConfig();
|
||||
};
|
||||
void run();
|
||||
}, [fetchConfig]);
|
||||
|
||||
// Computed runtime
|
||||
|
||||
@@ -8,6 +8,9 @@ import { API_BASE_URL } from "@/utils/config";
|
||||
import { authenticatedFetch } from "@/lib/authenticatedFetch";
|
||||
import { ProtectedRoute } from "@/components/ProtectedRoute";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAggregatedAdminScope } from "@/hooks";
|
||||
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
||||
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
||||
import {
|
||||
ANNOTATION_VERDICTS,
|
||||
LANE_LABELS,
|
||||
@@ -20,10 +23,14 @@ import {
|
||||
} 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.
|
||||
* 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
|
||||
* backend excludes the platform-internal globaladmin lane for tenant callers.
|
||||
*
|
||||
* Cross-tenant: a GA/Reader gets the tenant switcher incl. the "All tenants" aggregate
|
||||
* (the same evaluation stream MCP's list_session_annotations serves); a delegated
|
||||
* ("MSP") admin gets the switcher bounded to its managed tenants.
|
||||
*/
|
||||
|
||||
interface AnnotationRow {
|
||||
@@ -53,20 +60,34 @@ export default function AnnotationsPage() {
|
||||
const [verdictFilter, setVerdictFilter] = useState("");
|
||||
const [laneFilter, setLaneFilter] = useState("");
|
||||
|
||||
// GA/Reader: "All tenants" aggregate by default (mirrors the MCP evaluation stream) with
|
||||
// per-tenant drill-down; delegated ("MSP"): managed tenants only, never aggregated.
|
||||
const scope = useAggregatedAdminScope({ defaultAggregated: true });
|
||||
const { isGlobalAdmin: crossTenant, routeGlobal, selectedTenantId, scopeInitialized, tenants } = scope;
|
||||
|
||||
const lanes = visibleLanes(user);
|
||||
|
||||
const tenantLabel = (id: string | null | undefined) =>
|
||||
tenants.find((t) => t.tenantId === id)?.domainName ?? id ?? "—";
|
||||
|
||||
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}`);
|
||||
if (res.status === 404) {
|
||||
// Deploy skew: a backend without the route yet. Reads as "nothing annotated",
|
||||
// never as an error — the empty state below carries the call to action.
|
||||
if (!append) setRows([]);
|
||||
setNextLink(null);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(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");
|
||||
} catch {
|
||||
// Non-technical by design — the status text means nothing to the reader.
|
||||
setLoadError("Annotations could not be loaded right now — please try again in a moment.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -75,24 +96,46 @@ export default function AnnotationsPage() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPage(
|
||||
api.annotations.list({
|
||||
// Wait for the scope's default selection to settle so we never fire a request in the
|
||||
// wrong scope. Inner async wrapper: set-state-in-effect flags a direct call to a
|
||||
// state-setting callback even when every setState sits behind an await.
|
||||
if (!scopeInitialized) return;
|
||||
const loadFirstPage = async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const filters = {
|
||||
verdict: verdictFilter || undefined,
|
||||
lane: laneFilter || undefined,
|
||||
}),
|
||||
false
|
||||
);
|
||||
}, [fetchPage, verdictFilter, laneFilter]);
|
||||
};
|
||||
await fetchPage(
|
||||
routeGlobal
|
||||
? api.annotations.globalList({ ...filters, tenantId: selectedTenantId || undefined })
|
||||
: api.annotations.list(filters),
|
||||
false
|
||||
);
|
||||
};
|
||||
void loadFirstPage();
|
||||
}, [fetchPage, verdictFilter, laneFilter, scopeInitialized, routeGlobal, selectedTenantId]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<GlobalAdminBanner
|
||||
show={crossTenant}
|
||||
delegated={scope.isDelegatedScope}
|
||||
subtitle={globalAdminSubtitle(scope, "aggregating annotations across all tenants")}
|
||||
/>
|
||||
<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 className="flex flex-wrap items-center justify-between gap-y-3">
|
||||
<div>
|
||||
<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>
|
||||
<TenantScopeSelector scope={scope} allowAggregated />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -124,7 +167,7 @@ export default function AnnotationsPage() {
|
||||
</div>
|
||||
|
||||
{loadError ? (
|
||||
<p className="text-sm text-red-600">{loadError}</p>
|
||||
<p className="text-sm text-amber-700">{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{" "}
|
||||
@@ -136,6 +179,7 @@ export default function AnnotationsPage() {
|
||||
<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">
|
||||
{crossTenant && <th className="px-3 py-2">Tenant</th>}
|
||||
<th className="px-3 py-2">Verdict</th>
|
||||
<th className="px-3 py-2">Role</th>
|
||||
<th className="px-3 py-2">Note</th>
|
||||
@@ -146,7 +190,12 @@ export default function AnnotationsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((row) => (
|
||||
<tr key={`${row.sessionId}_${row.lane}`} className="hover:bg-gray-50">
|
||||
<tr key={`${row.tenantId ?? ""}_${row.sessionId}_${row.lane}`} className="hover:bg-gray-50">
|
||||
{crossTenant && (
|
||||
<td className="px-3 py-2 whitespace-nowrap text-gray-700">
|
||||
{tenantLabel(row.tenantId)}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{row.verdict != null ? (
|
||||
<span
|
||||
@@ -173,7 +222,10 @@ export default function AnnotationsPage() {
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<Link
|
||||
href={sessionUrl(row.sessionId, { hash: "section-annotations" })}
|
||||
href={sessionUrl(row.sessionId, {
|
||||
tenantId: crossTenant ? row.tenantId || undefined : undefined,
|
||||
hash: "section-annotations",
|
||||
})}
|
||||
className="text-green-700 hover:text-green-800 underline underline-offset-2"
|
||||
>
|
||||
open session
|
||||
@@ -190,7 +242,11 @@ export default function AnnotationsPage() {
|
||||
{loading && <span className="text-sm text-gray-400">Loading…</span>}
|
||||
{!loading && nextLink && (
|
||||
<button
|
||||
onClick={() => fetchPage(`${API_BASE_URL}${nextLink}`, true)}
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
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
|
||||
|
||||
@@ -165,8 +165,15 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
|
||||
return rows;
|
||||
}, [data, search, sortKey, sortDir]);
|
||||
|
||||
// Reset to the first page whenever the filtered/sorted set or the loaded data changes.
|
||||
useEffect(() => { setPage(0); }, [search, sortKey, sortDir, data]);
|
||||
// Reset to the first page whenever the filtered/sorted set or the loaded data
|
||||
// changes (adjust-during-render pattern, see react.dev "storing information
|
||||
// from previous renders"). `data` compares by reference, exactly like the
|
||||
// dependency array of the effect this replaces did.
|
||||
const [prevPageResetKey, setPrevPageResetKey] = useState<[string, SortKey, SortDir, AppsListResponse | null]>([search, sortKey, sortDir, data]);
|
||||
if (prevPageResetKey[0] !== search || prevPageResetKey[1] !== sortKey || prevPageResetKey[2] !== sortDir || prevPageResetKey[3] !== data) {
|
||||
setPrevPageResetKey([search, sortKey, sortDir, data]);
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredAndSorted.length / PAGE_SIZE));
|
||||
const pageRows = filteredAndSorted.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
|
||||
|
||||
@@ -50,10 +50,10 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
|
||||
useEffect(() => {
|
||||
if (!scopeInitialized) return;
|
||||
if (!isGlobalAdmin && !tenantId) return;
|
||||
if (needsTenantPick) { setResp(null); setLoading(false); return; }
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
if (needsTenantPick) { setResp(null); setLoading(false); return; }
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = routeGlobal
|
||||
|
||||
@@ -358,8 +358,11 @@ function AppDetailContent() {
|
||||
// wrong scope before the GA default-to-own-tenant has settled.
|
||||
useEffect(() => {
|
||||
if (!scopeInitialized) return;
|
||||
setSessionsOffset(0);
|
||||
Promise.all([fetchAnalytics(), fetchSessions(0, statusFilter)]);
|
||||
const run = async () => {
|
||||
setSessionsOffset(0);
|
||||
await Promise.all([fetchAnalytics(), fetchSessions(0, statusFilter)]);
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopeInitialized, days, scopeKey]);
|
||||
|
||||
|
||||
@@ -156,10 +156,13 @@ export default function AuditPage() {
|
||||
// delegated caller) so we don't fire a wasted request in the wrong scope. scopeKey changes on a
|
||||
// tenant/GA-mode switch → refetch from page 1.
|
||||
if (!scopeInitialized) return;
|
||||
setContinuation(null);
|
||||
setContinuationStack([]);
|
||||
setPageNumber(1);
|
||||
fetchPage(null, true);
|
||||
const run = async () => {
|
||||
setContinuation(null);
|
||||
setContinuationStack([]);
|
||||
setPageNumber(1);
|
||||
await fetchPage(null, true);
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopeKey, scopeInitialized, dateFromIso, dateToIso, excludeDeletions]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
const tips = [
|
||||
"Auto-set the device timezone under Settings \u2192 Agent Settings.",
|
||||
@@ -19,12 +19,21 @@ const tips = [
|
||||
"Spotted a session with the wrong status? Use Report Session to flag it to the team.",
|
||||
];
|
||||
|
||||
export default function TipOfTheDay() {
|
||||
const [tip, setTip] = useState("");
|
||||
// One random tip per mount, read via useSyncExternalStore: the server snapshot is ""
|
||||
// (renders nothing, matching the prerendered HTML — Math.random() at build time would
|
||||
// freeze one tip and mismatch on hydration); the client re-render then shows the pick.
|
||||
let chosenTip: string | null = null;
|
||||
const subscribeNever = () => () => {};
|
||||
const getTipSnapshot = () => {
|
||||
if (chosenTip === null) chosenTip = tips[Math.floor(Math.random() * tips.length)];
|
||||
return chosenTip;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTip(tips[Math.floor(Math.random() * tips.length)]);
|
||||
}, []);
|
||||
export default function TipOfTheDay() {
|
||||
const tip = useSyncExternalStore(subscribeNever, getTipSnapshot, () => "");
|
||||
|
||||
// Re-roll on the next mount so each dashboard visit gets a fresh tip.
|
||||
useEffect(() => () => { chosenTip = null; }, []);
|
||||
|
||||
if (!tip) return null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { NotificationType } from "@/contexts/NotificationContext";
|
||||
@@ -10,6 +10,9 @@ interface BlockTarget {
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
// Module-level so the masked value returned while scope is off is referentially stable.
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
export function useBlockDevice(
|
||||
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
|
||||
addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string) => void,
|
||||
@@ -21,12 +24,11 @@ export function useBlockDevice(
|
||||
const [blockingDevice, setBlockingDevice] = useState(false);
|
||||
const [blockedDevicesSet, setBlockedDevicesSet] = useState<Set<string>>(new Set());
|
||||
|
||||
// Reset blocked set when admin mode or global admin mode is turned off
|
||||
useEffect(() => {
|
||||
if (!adminMode || !globalAdminMode) {
|
||||
setBlockedDevicesSet(new Set());
|
||||
}
|
||||
}, [adminMode, globalAdminMode]);
|
||||
// Blocked devices are only meaningful while admin mode AND global admin mode are
|
||||
// both on — mask the set at read instead of clearing state in an effect. The raw
|
||||
// state stays owned by useDashboardSessions.fetchBlockedDevices, which replaces
|
||||
// it wholesale on every fetch (and empties it while scope is off).
|
||||
const hasBlockScope = adminMode && globalAdminMode;
|
||||
|
||||
const blockDevice = (serialNumber: string, tenantId: string, deviceName?: string) => {
|
||||
setSessionToBlock({ serialNumber, tenantId, deviceName });
|
||||
@@ -85,7 +87,7 @@ export function useBlockDevice(
|
||||
showBlockConfirm,
|
||||
sessionToBlock,
|
||||
blockingDevice,
|
||||
blockedDevicesSet,
|
||||
blockedDevicesSet: hasBlockScope ? blockedDevicesSet : EMPTY_SET,
|
||||
setBlockedDevicesSet,
|
||||
blockDevice,
|
||||
confirmBlock,
|
||||
|
||||
@@ -7,6 +7,17 @@ import type { Session } from "../types";
|
||||
const SESSIONS_PER_PAGE_KEY = "sessionsPerPage";
|
||||
const DEFAULT_SESSIONS_PER_PAGE = 10;
|
||||
|
||||
// Deterministic serialization of the column filters (sorted keys, sorted set values)
|
||||
// so structurally identical filter content always yields the same page-reset key
|
||||
// regardless of insertion order.
|
||||
function serializeColumnFilters(filters: Record<string, Set<string>>): string {
|
||||
return JSON.stringify(
|
||||
Object.keys(filters)
|
||||
.sort()
|
||||
.map((key) => [key, Array.from(filters[key]).sort()])
|
||||
);
|
||||
}
|
||||
|
||||
interface UseDashboardFiltersParams {
|
||||
sessions: Session[];
|
||||
blockedDevicesSet: Set<string>;
|
||||
@@ -88,9 +99,18 @@ export function useDashboardFilters({
|
||||
|
||||
// Reset to page 1 whenever the displayed set or its ordering changes —
|
||||
// includes external scope changes (tenant switch, global-admin toggle, tenant filter).
|
||||
useEffect(() => {
|
||||
// React's adjust-during-render pattern (compare the previous key, set state while
|
||||
// rendering) instead of an effect, so the stale page never paints first.
|
||||
const pageResetKey = JSON.stringify([
|
||||
searchQuery, statusFilter, sortColumn, sortDirection,
|
||||
serializeColumnFilters(columnFilters), sessionsPerPage,
|
||||
globalAdminMode, tenantIdFilter, tenantId ?? null,
|
||||
]);
|
||||
const [prevPageResetKey, setPrevPageResetKey] = useState(pageResetKey);
|
||||
if (prevPageResetKey !== pageResetKey) {
|
||||
setPrevPageResetKey(pageResetKey);
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, statusFilter, sortColumn, sortDirection, columnFilters, sessionsPerPage, globalAdminMode, tenantIdFilter, tenantId]);
|
||||
}
|
||||
|
||||
const handleSessionsPerPageChange = (value: number) => {
|
||||
setSessionsPerPage(value);
|
||||
@@ -194,45 +214,47 @@ export function useDashboardFilters({
|
||||
}, [filteredSessions, sortColumn, sortDirection]);
|
||||
|
||||
const totalPages = Math.ceil(sortedSessions.length / sessionsPerPage);
|
||||
const paginatedSessions = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * sessionsPerPage;
|
||||
return sortedSessions.slice(startIndex, startIndex + sessionsPerPage);
|
||||
}, [sortedSessions, currentPage, sessionsPerPage]);
|
||||
|
||||
const handlePreviousPage = () => setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||
// Snap currentPage back to the last valid page when a load finished that did NOT
|
||||
// grow totalPages (e.g. an active filter narrowed the new batch to zero matches).
|
||||
// Without this the user would be stuck on an empty paginated slice with no way
|
||||
// to recover except clicking Prev. Clamped at read (not written back via an
|
||||
// effect): gated on !loadingMore so a legitimate overshoot is never reverted
|
||||
// mid-fetch, and the moment the load finishes the clamp applies in the very
|
||||
// same render — an effect-based snap-back could otherwise race handleNextPage
|
||||
// (loadingMore still false when the effect ran) and cancel the advance.
|
||||
const effectivePage =
|
||||
!loadingMore && totalPages > 0 && currentPage > totalPages ? totalPages : currentPage;
|
||||
|
||||
const paginatedSessions = useMemo(() => {
|
||||
const startIndex = (effectivePage - 1) * sessionsPerPage;
|
||||
return sortedSessions.slice(startIndex, startIndex + sessionsPerPage);
|
||||
}, [sortedSessions, effectivePage, sessionsPerPage]);
|
||||
|
||||
// Handlers step from effectivePage (the clamped value every consumer sees) so a
|
||||
// raw overshoot left in state can never compound or make Prev a visual no-op.
|
||||
const handlePreviousPage = () => setCurrentPage(Math.max(1, effectivePage - 1));
|
||||
// When advancing past locally-loaded pages, fire loadMore() in the SAME event
|
||||
// handler so React batches setLoadingMore(true) with setCurrentPage(prev+1).
|
||||
// If we relied on a follow-up effect to trigger the fetch, the snap-back effect
|
||||
// below would race ahead (loadingMore still false at that point) and reset
|
||||
// currentPage to totalPages — leaving the user on the previous page even though
|
||||
// a load was in flight, forcing a second click to actually advance.
|
||||
// handler so React batches setLoadingMore(true) with setCurrentPage(...): the
|
||||
// clamp above then sees loadingMore === true on the next render and lets the
|
||||
// overshoot page stand while the fetch is in flight.
|
||||
const handleNextPage = () => {
|
||||
if (currentPage < totalPages) {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
if (effectivePage < totalPages) {
|
||||
setCurrentPage(effectivePage + 1);
|
||||
return;
|
||||
}
|
||||
if (hasMore && !loadingMore) {
|
||||
loadMore();
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
setCurrentPage(effectivePage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Snap currentPage back to the last valid page once a load finishes that did NOT
|
||||
// grow totalPages (e.g. an active filter narrowed the new batch to zero matches).
|
||||
// Without this the user would be stuck on an empty paginated slice with no way
|
||||
// to recover except clicking Prev. Gated on !loadingMore so it never fires
|
||||
// mid-fetch and reverts a legitimate overshoot.
|
||||
useEffect(() => {
|
||||
if (loadingMore) return;
|
||||
if (totalPages > 0 && currentPage > totalPages) setCurrentPage(totalPages);
|
||||
}, [currentPage, totalPages, loadingMore]);
|
||||
|
||||
return {
|
||||
searchQuery, setSearchQuery,
|
||||
statusFilter, setStatusFilter,
|
||||
sortColumn, sortDirection, handleSort,
|
||||
columnFilters, setColumnFilters,
|
||||
currentPage, sessionsPerPage, handleSessionsPerPageChange,
|
||||
currentPage: effectivePage, sessionsPerPage, handleSessionsPerPageChange,
|
||||
handlePreviousPage, handleNextPage,
|
||||
effectiveSessions, filteredSessions, sortedSessions, paginatedSessions,
|
||||
totalPages,
|
||||
|
||||
@@ -201,8 +201,11 @@ export function useDashboardStats({
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
if (!globalAdminMode && !tenantId) return;
|
||||
setStats(null);
|
||||
fetchStats();
|
||||
const run = async () => {
|
||||
setStats(null);
|
||||
await fetchStats();
|
||||
};
|
||||
void run();
|
||||
// fetchStats is stable; intentionally not in deps to avoid re-running on identity flips.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantId, globalAdminMode, submittedTenantIdFilter, days, disabled]);
|
||||
|
||||
@@ -21,12 +21,12 @@ export function useTenantList(
|
||||
const [tenantList, setTenantList] = useState<TenantListItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!globalAdminMode) {
|
||||
setTenantList([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const run = async () => {
|
||||
if (!globalAdminMode) {
|
||||
setTenantList([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await authenticatedFetch(api.config.all(), getAccessToken);
|
||||
if (!res.ok || cancelled) return;
|
||||
@@ -40,7 +40,8 @@ export function useTenantList(
|
||||
} catch {
|
||||
// Non-critical — tenant autocomplete just won't work
|
||||
}
|
||||
})();
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [globalAdminMode, getAccessToken]);
|
||||
|
||||
|
||||
@@ -212,12 +212,17 @@ function HomeContent() {
|
||||
// Clear the tenant filter when cross-tenant mode turns off (refetch is owned by useDashboardSessions).
|
||||
// Keyed on crossTenant (not raw globalAdminMode) so a delegated ("MSP") admin — whose crossTenant is
|
||||
// always on — keeps any `?tenant=` deep-link / typed filter instead of having it wiped on mount.
|
||||
useEffect(() => {
|
||||
// Adjust-during-render (compare-prev) instead of an effect; prev starts at null so the first
|
||||
// render replicates the old effect's mount-time run, which cleared a stray `?tenant=`
|
||||
// deep-link for users who are not in a cross-tenant view.
|
||||
const [prevCrossTenant, setPrevCrossTenant] = useState<boolean | null>(null);
|
||||
if (prevCrossTenant !== crossTenant) {
|
||||
setPrevCrossTenant(crossTenant);
|
||||
if (!crossTenant) {
|
||||
setTenantIdFilter("");
|
||||
setSubmittedTenantIdFilter("");
|
||||
}
|
||||
}, [crossTenant]);
|
||||
}
|
||||
|
||||
// Auto-load more when the user needs more sessions than currently loaded
|
||||
// (e.g. increased sessionsPerPage, paginated forward, or applied a sort/column filter
|
||||
|
||||
@@ -158,33 +158,39 @@ function DiagnosisContent() {
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
|
||||
sessionIdRef.current = sessionId;
|
||||
if (lastFetchedSessionId.current !== sessionId) {
|
||||
hasInitialFetch.current = false;
|
||||
lastFetchedSessionId.current = sessionId;
|
||||
sessionFetchDone.current = false;
|
||||
analysisFetchDone.current = false;
|
||||
setSessionTenantId(null);
|
||||
setLoading(true);
|
||||
}
|
||||
if (hasInitialFetch.current) return;
|
||||
hasInitialFetch.current = true;
|
||||
const run = async () => {
|
||||
sessionIdRef.current = sessionId;
|
||||
if (lastFetchedSessionId.current !== sessionId) {
|
||||
hasInitialFetch.current = false;
|
||||
lastFetchedSessionId.current = sessionId;
|
||||
sessionFetchDone.current = false;
|
||||
analysisFetchDone.current = false;
|
||||
setSessionTenantId(null);
|
||||
setLoading(true);
|
||||
}
|
||||
if (hasInitialFetch.current) return;
|
||||
hasInitialFetch.current = true;
|
||||
|
||||
// Performance: eager-set sessionTenantId from TenantContext when known, so
|
||||
// the events/analysis effect fires in parallel with fetchSessionDetails
|
||||
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
|
||||
// Global Admins in all-tenant view fall through with null (backend resolves
|
||||
// the tenant from the session itself).
|
||||
if (!globalAdminMode && isGuid(tenantId)) {
|
||||
setSessionTenantId(tenantId);
|
||||
}
|
||||
// Performance: eager-set sessionTenantId from TenantContext when known, so
|
||||
// the events/analysis effect fires in parallel with fetchSessionDetails
|
||||
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
|
||||
// Global Admins in all-tenant view fall through with null (backend resolves
|
||||
// the tenant from the session itself).
|
||||
if (!globalAdminMode && isGuid(tenantId)) {
|
||||
setSessionTenantId(tenantId);
|
||||
}
|
||||
|
||||
fetchSessionDetails();
|
||||
await fetchSessionDetails();
|
||||
};
|
||||
void run();
|
||||
}, [sessionId, tenantId, globalAdminMode, fetchSessionDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionTenantId && sessionId) {
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
const run = async () => {
|
||||
await Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
};
|
||||
void run();
|
||||
}
|
||||
}, [sessionTenantId, sessionId, fetchEvents, fetchAnalysisResults]);
|
||||
|
||||
|
||||
@@ -210,8 +210,11 @@ export function useFleetHealth({
|
||||
// previous-scope numbers don't linger if the refetch errors out.
|
||||
useEffect(() => {
|
||||
if (!scopeInitialized) return;
|
||||
setData(null);
|
||||
fetchData();
|
||||
const run = async () => {
|
||||
setData(null);
|
||||
await fetchData();
|
||||
};
|
||||
void run();
|
||||
// fetchData is stable; scopeKey/days drive the refetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopeInitialized, scopeKey, days]);
|
||||
|
||||
@@ -103,7 +103,10 @@ export default function FleetHealthPage() {
|
||||
// App install metrics are already backend-aggregated; fetch alongside fleet data.
|
||||
useEffect(() => {
|
||||
if (!scopeInitialized) return;
|
||||
fetchAppMetrics(timeRange);
|
||||
const run = async () => {
|
||||
await fetchAppMetrics(timeRange);
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopeInitialized, timeRange, scopeKey]);
|
||||
|
||||
|
||||
@@ -37,16 +37,16 @@ export function useFleetSummaries(tenantIds: string[], days: number, homeTenantI
|
||||
const key = [...tenantIds].sort().join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantIds.length === 0) {
|
||||
setSummaries({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
const run = async () => {
|
||||
if (tenantIds.length === 0) {
|
||||
setSummaries({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
const queue = [...tenantIds];
|
||||
const result: Record<string, FleetSummary> = {};
|
||||
|
||||
@@ -79,7 +79,7 @@ export function useFleetSummaries(tenantIds: string[], days: number, homeTenantI
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -106,10 +106,10 @@ export default function GatherRulesPage() {
|
||||
// badge showing the GA's HOME-tenant mode for every foreign tenant.)
|
||||
useEffect(() => {
|
||||
if (!effectiveTenantId) return;
|
||||
// Reset first so a failed/forbidden fetch never carries the previous tenant's mode over.
|
||||
setUnrestrictedMode(false);
|
||||
let stale = false;
|
||||
const fetchConfig = async () => {
|
||||
const run = async () => {
|
||||
// Reset first so a failed/forbidden fetch never carries the previous tenant's mode over.
|
||||
setUnrestrictedMode(false);
|
||||
try {
|
||||
const response = await authenticatedFetch(api.config.featureFlags(effectiveTenantId), getAccessToken);
|
||||
if (!stale && response.ok) {
|
||||
@@ -120,7 +120,7 @@ export default function GatherRulesPage() {
|
||||
// Silently default to restricted mode
|
||||
}
|
||||
};
|
||||
fetchConfig();
|
||||
void run();
|
||||
return () => { stale = true; };
|
||||
}, [effectiveTenantId, getAccessToken]);
|
||||
|
||||
|
||||
@@ -187,12 +187,15 @@ export default function GeographicPerformancePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!scopeInitialized) return;
|
||||
if (isTimeRangeMount.current) {
|
||||
isTimeRangeMount.current = false;
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
fetchGeoMetrics(timeRange, groupBy);
|
||||
const run = async () => {
|
||||
if (isTimeRangeMount.current) {
|
||||
isTimeRangeMount.current = false;
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
await fetchGeoMetrics(timeRange, groupBy);
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopeInitialized, timeRange, groupBy, scopeKey]);
|
||||
|
||||
|
||||
@@ -109,8 +109,10 @@ export default function HealthCheckPage() {
|
||||
// fast backend checks render immediately while the MCP probe (possible cold start) catches up.
|
||||
useEffect(() => {
|
||||
if (user && !hasRun) {
|
||||
performHealthCheck();
|
||||
performMcpCheck();
|
||||
const run = async () => {
|
||||
await Promise.all([performHealthCheck(), performMcpCheck()]);
|
||||
};
|
||||
void run();
|
||||
}
|
||||
}, [user, hasRun, performHealthCheck, performMcpCheck]);
|
||||
|
||||
|
||||
@@ -54,6 +54,62 @@ export interface CurrentInstall {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// Module-level pure helper: the loop with its Map mutations and early return keeps the
|
||||
// React compiler from memoizing the body inline (preserve-manual-memoization).
|
||||
function computeCurrentDownload(events: EnrollmentEvent[]): CurrentDownload | null {
|
||||
const downloadEvents = events.filter((e) => e.eventType === "download_progress");
|
||||
if (downloadEvents.length === 0) return null;
|
||||
|
||||
const appLatest = new Map<
|
||||
string,
|
||||
{ bytesDownloaded: number; bytesTotal: number; downloadRateBps: number; isComplete: boolean }
|
||||
>();
|
||||
for (const evt of downloadEvents) {
|
||||
const d = evt.data as ProgressEventData | undefined;
|
||||
if (!d) continue;
|
||||
const appName = d.app_name ?? d.appName ?? d.file_name ?? d.fileName ?? null;
|
||||
if (!appName) continue;
|
||||
const bytesDownloaded = Number(d.bytes_downloaded ?? d.bytesDownloaded ?? 0);
|
||||
const bytesTotal = Number(d.bytes_total ?? d.bytesTotal ?? 0);
|
||||
const downloadRateBps = Number(d.download_rate_bps ?? d.downloadRateBps ?? 0);
|
||||
const status = d.status ?? "";
|
||||
const isComplete =
|
||||
status === "completed" ||
|
||||
status === "failed" ||
|
||||
d.isCompleted === true ||
|
||||
d.is_completed === true;
|
||||
appLatest.set(appName, {
|
||||
bytesDownloaded: isNaN(bytesDownloaded) ? 0 : bytesDownloaded,
|
||||
bytesTotal: isNaN(bytesTotal) ? 0 : bytesTotal,
|
||||
downloadRateBps: isNaN(downloadRateBps) ? 0 : downloadRateBps,
|
||||
isComplete,
|
||||
});
|
||||
}
|
||||
|
||||
const completedCount = Array.from(appLatest.values()).filter((v) => v.isComplete).length;
|
||||
|
||||
for (let i = downloadEvents.length - 1; i >= 0; i--) {
|
||||
const d = downloadEvents[i].data as ProgressEventData | undefined;
|
||||
if (!d) continue;
|
||||
const appName = d.app_name ?? d.appName ?? d.file_name ?? d.fileName ?? null;
|
||||
if (!appName) continue;
|
||||
const latest = appLatest.get(appName);
|
||||
if (latest && !latest.isComplete) {
|
||||
return { appName, ...latest, completedCount, active: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appName: null,
|
||||
bytesDownloaded: 0,
|
||||
bytesTotal: 0,
|
||||
downloadRateBps: 0,
|
||||
isComplete: true,
|
||||
completedCount,
|
||||
active: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseProgressDerivedDataReturn {
|
||||
appSummary: AppSummary | null;
|
||||
currentDownload: CurrentDownload | null;
|
||||
@@ -110,59 +166,10 @@ export function useProgressDerivedData(
|
||||
return null;
|
||||
}, [events]);
|
||||
|
||||
const currentDownload = useMemo<CurrentDownload | null>(() => {
|
||||
const downloadEvents = events.filter((e) => e.eventType === "download_progress");
|
||||
if (downloadEvents.length === 0) return null;
|
||||
|
||||
const appLatest = new Map<
|
||||
string,
|
||||
{ bytesDownloaded: number; bytesTotal: number; downloadRateBps: number; isComplete: boolean }
|
||||
>();
|
||||
for (const evt of downloadEvents) {
|
||||
const d = evt.data as ProgressEventData | undefined;
|
||||
if (!d) continue;
|
||||
const appName = d.app_name ?? d.appName ?? d.file_name ?? d.fileName ?? null;
|
||||
if (!appName) continue;
|
||||
const bytesDownloaded = Number(d.bytes_downloaded ?? d.bytesDownloaded ?? 0);
|
||||
const bytesTotal = Number(d.bytes_total ?? d.bytesTotal ?? 0);
|
||||
const downloadRateBps = Number(d.download_rate_bps ?? d.downloadRateBps ?? 0);
|
||||
const status = d.status ?? "";
|
||||
const isComplete =
|
||||
status === "completed" ||
|
||||
status === "failed" ||
|
||||
d.isCompleted === true ||
|
||||
d.is_completed === true;
|
||||
appLatest.set(appName, {
|
||||
bytesDownloaded: isNaN(bytesDownloaded) ? 0 : bytesDownloaded,
|
||||
bytesTotal: isNaN(bytesTotal) ? 0 : bytesTotal,
|
||||
downloadRateBps: isNaN(downloadRateBps) ? 0 : downloadRateBps,
|
||||
isComplete,
|
||||
});
|
||||
}
|
||||
|
||||
const completedCount = Array.from(appLatest.values()).filter((v) => v.isComplete).length;
|
||||
|
||||
for (let i = downloadEvents.length - 1; i >= 0; i--) {
|
||||
const d = downloadEvents[i].data as ProgressEventData | undefined;
|
||||
if (!d) continue;
|
||||
const appName = d.app_name ?? d.appName ?? d.file_name ?? d.fileName ?? null;
|
||||
if (!appName) continue;
|
||||
const latest = appLatest.get(appName);
|
||||
if (latest && !latest.isComplete) {
|
||||
return { appName, ...latest, completedCount, active: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appName: null,
|
||||
bytesDownloaded: 0,
|
||||
bytesTotal: 0,
|
||||
downloadRateBps: 0,
|
||||
isComplete: true,
|
||||
completedCount,
|
||||
active: false,
|
||||
};
|
||||
}, [events]);
|
||||
const currentDownload = useMemo<CurrentDownload | null>(
|
||||
() => computeCurrentDownload(events),
|
||||
[events]
|
||||
);
|
||||
|
||||
const currentInstall = useMemo<CurrentInstall | null>(() => {
|
||||
const installTypes = new Set([
|
||||
@@ -234,19 +241,26 @@ export function useProgressDerivedData(
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
const [installElapsedMs, setInstallElapsedMs] = useState<number | null>(null);
|
||||
// Live 1s install timer. The tick state carries the startedAt it was computed
|
||||
// from so the returned value can mask stale ticks from a previous install at
|
||||
// read time — the effect's inactive branch just declines to start an interval
|
||||
// instead of writing a null reset back into state.
|
||||
const [installTimer, setInstallTimer] = useState<{ startedAt: string; elapsedMs: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!currentInstall?.active || !currentInstall?.startedAt) {
|
||||
setInstallElapsedMs(null);
|
||||
return;
|
||||
}
|
||||
const startTime = new Date(currentInstall.startedAt).getTime();
|
||||
const tick = () => setInstallElapsedMs(Date.now() - startTime);
|
||||
if (!currentInstall?.active || !currentInstall?.startedAt) return;
|
||||
const startedAt = currentInstall.startedAt;
|
||||
const startTime = new Date(startedAt).getTime();
|
||||
const tick = () => setInstallTimer({ startedAt, elapsedMs: Date.now() - startTime });
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [currentInstall?.active, currentInstall?.startedAt]);
|
||||
|
||||
const installElapsedMs =
|
||||
currentInstall?.active && currentInstall.startedAt && installTimer?.startedAt === currentInstall.startedAt
|
||||
? installTimer.elapsedMs
|
||||
: null;
|
||||
|
||||
const overallProgress = session
|
||||
? session.status === "Succeeded"
|
||||
? 100
|
||||
|
||||
@@ -54,61 +54,64 @@ export function useProgressEvents({
|
||||
|
||||
// Reset per-session fetch guard + events when the selected session changes
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
lastFetchedSessionId.current = null;
|
||||
setEvents([]);
|
||||
return;
|
||||
}
|
||||
if (lastFetchedSessionId.current === session.sessionId) return;
|
||||
lastFetchedSessionId.current = session.sessionId;
|
||||
setEvents([]);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
api.progress.sessionEvents(session.sessionId, tenantId),
|
||||
getAccessToken,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const fetched: EnrollmentEvent[] = data.events || [];
|
||||
setEvents((prev) => {
|
||||
if (prev.length === 0) return fetched;
|
||||
const existingIds = new Set(prev.map((e) => e.eventId));
|
||||
const newEvents = fetched.filter((e) => !existingIds.has(e.eventId));
|
||||
if (newEvents.length === 0) return prev;
|
||||
return [...prev, ...newEvents].sort(
|
||||
(a, b) => a.sequence - b.sequence,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
addNotification(
|
||||
"error",
|
||||
"Backend Error",
|
||||
`Failed to load enrollment events: ${response.statusText}`,
|
||||
"progress-events-error",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
addNotification(
|
||||
"error",
|
||||
"Session Expired",
|
||||
error.message,
|
||||
"session-expired-error",
|
||||
);
|
||||
} else {
|
||||
console.error("Failed to fetch events:", error);
|
||||
addNotification(
|
||||
"error",
|
||||
"Backend Not Reachable",
|
||||
"Unable to load enrollment events. Please check your connection.",
|
||||
"progress-events-error",
|
||||
);
|
||||
}
|
||||
const run = async () => {
|
||||
if (!session) {
|
||||
lastFetchedSessionId.current = null;
|
||||
setEvents([]);
|
||||
return;
|
||||
}
|
||||
if (lastFetchedSessionId.current === session.sessionId) return;
|
||||
lastFetchedSessionId.current = session.sessionId;
|
||||
setEvents([]);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
api.progress.sessionEvents(session.sessionId, tenantId),
|
||||
getAccessToken,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const fetched: EnrollmentEvent[] = data.events || [];
|
||||
setEvents((prev) => {
|
||||
if (prev.length === 0) return fetched;
|
||||
const existingIds = new Set(prev.map((e) => e.eventId));
|
||||
const newEvents = fetched.filter((e) => !existingIds.has(e.eventId));
|
||||
if (newEvents.length === 0) return prev;
|
||||
return [...prev, ...newEvents].sort(
|
||||
(a, b) => a.sequence - b.sequence,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
addNotification(
|
||||
"error",
|
||||
"Backend Error",
|
||||
`Failed to load enrollment events: ${response.statusText}`,
|
||||
"progress-events-error",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
addNotification(
|
||||
"error",
|
||||
"Session Expired",
|
||||
error.message,
|
||||
"session-expired-error",
|
||||
);
|
||||
} else {
|
||||
console.error("Failed to fetch events:", error);
|
||||
addNotification(
|
||||
"error",
|
||||
"Backend Not Reachable",
|
||||
"Unable to load enrollment events. Please check your connection.",
|
||||
"progress-events-error",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
await fetchEvents();
|
||||
};
|
||||
fetchEvents();
|
||||
void run();
|
||||
}, [session, tenantId, getAccessToken, addNotification]);
|
||||
|
||||
const scheduleFetchEvents = useCallback(
|
||||
|
||||
@@ -135,6 +135,11 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
: null;
|
||||
|
||||
const uptimeUntilEnrollment = useMemo(() => {
|
||||
// bootTime is derived from events inside the memo: the outer const is a live alias
|
||||
// into event data, which the compiler treats as maybe-mutated and would refuse to
|
||||
// memoize against (preserve-manual-memoization).
|
||||
const bootEvents = events.filter(e => e.eventType === "boot_time");
|
||||
const bootTime = (bootEvents[bootEvents.length - 1]?.data as BootTimeData | undefined) ?? null;
|
||||
if (!bootTime || events.length === 0) return null;
|
||||
const bootTimeStr = bootTime.bootTimeUtc ?? bootTime.bootTime;
|
||||
if (!bootTimeStr) return null;
|
||||
@@ -159,7 +164,7 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [bootTime, events]);
|
||||
}, [events]);
|
||||
|
||||
const osInfo = getEventData<OsInfoData>("os_info");
|
||||
const networkAdapters = getEventData<NetworkAdaptersData>("network_adapters");
|
||||
|
||||
@@ -72,12 +72,12 @@ export default function DeviceHistoryBanner({
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !serialNumber) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const run = async () => {
|
||||
if (!sessionId || !serialNumber) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
api.metrics.deviceHistory(serialNumber, sessionId, effectiveTenantId),
|
||||
@@ -89,7 +89,8 @@ export default function DeviceHistoryBanner({
|
||||
} catch {
|
||||
// fail-soft: no banner
|
||||
}
|
||||
})();
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { Session, EnrollmentEvent, RuleResult } from "@/types";
|
||||
|
||||
const MAX_AGENT_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
@@ -48,12 +48,17 @@ export default function ReportSessionModal({
|
||||
const agentLogInputRef = useRef<HTMLInputElement>(null);
|
||||
const screenshotInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear stale result feedback from a previous submission the moment the modal
|
||||
// (re)opens. Adjust-during-render (compare-prev) instead of an effect — same
|
||||
// trigger (show flipping to true), but a stale banner can never paint first.
|
||||
const [prevShow, setPrevShow] = useState(show);
|
||||
if (prevShow !== show) {
|
||||
setPrevShow(show);
|
||||
if (show) {
|
||||
setSubmitResult(null);
|
||||
setSubmitErrorMessage(null);
|
||||
}
|
||||
}, [show]);
|
||||
}
|
||||
|
||||
if (!show || !session) return null;
|
||||
|
||||
|
||||
@@ -212,6 +212,14 @@ export default function SessionAnnotationsCard({
|
||||
<div key={lane} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-900">{LANE_LABELS[lane]}</span>
|
||||
{lane === "globaladmin" && (
|
||||
<span
|
||||
className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-violet-50 text-violet-700"
|
||||
title="This lane is platform-internal: it is filtered out of every tenant response server-side."
|
||||
>
|
||||
never visible to the tenant
|
||||
</span>
|
||||
)}
|
||||
{annotation?.verdict != null && !writable && (
|
||||
<span
|
||||
title={VERDICT_DESCRIPTIONS[annotation.verdict as AnnotationVerdict]}
|
||||
|
||||
@@ -68,7 +68,10 @@ export const VERDICT_PILL_CLASSES: Record<string, string> = {
|
||||
export const LANE_LABELS: Record<AnnotationLane, string> = {
|
||||
operator: "Operator",
|
||||
tenantadmin: "Tenant Admin",
|
||||
globaladmin: "Autopilot Monitor Team",
|
||||
// Only ever rendered for platform-scope users — the backend filters this lane out of
|
||||
// every tenant response. The label must read as INTERNAL, not as a message channel
|
||||
// from the vendor to the customer.
|
||||
globaladmin: "Internal (Autopilot Monitor)",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,7 +68,10 @@ export function useSessionAnalysis(
|
||||
// delay the analysis render.
|
||||
useEffect(() => {
|
||||
if (!sessionId || !sessionTenantId || !isGuid(sessionTenantId)) return;
|
||||
Promise.all([fetchAnalysisResults(), fetchVulnerabilityReport()]);
|
||||
const run = async () => {
|
||||
await Promise.all([fetchAnalysisResults(), fetchVulnerabilityReport()]);
|
||||
};
|
||||
void run();
|
||||
}, [sessionId, sessionTenantId, fetchAnalysisResults, fetchVulnerabilityReport]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -135,27 +135,30 @@ export function useSessionDetail({
|
||||
if (!sessionId) return;
|
||||
if (!globalAdminMode && !tenantId && !tenantIdOverride) return; // wait for a real target tenant
|
||||
|
||||
// Reset fetch flag only if navigating to a different session
|
||||
if (lastFetchedSessionId.current !== sessionId) {
|
||||
hasInitialFetch.current = false;
|
||||
lastFetchedSessionId.current = sessionId;
|
||||
setSessionTenantId(null);
|
||||
}
|
||||
const run = async () => {
|
||||
// Reset fetch flag only if navigating to a different session
|
||||
if (lastFetchedSessionId.current !== sessionId) {
|
||||
hasInitialFetch.current = false;
|
||||
lastFetchedSessionId.current = sessionId;
|
||||
setSessionTenantId(null);
|
||||
}
|
||||
|
||||
// Prevent duplicate fetches in React StrictMode (development double-mounting)
|
||||
if (hasInitialFetch.current) return;
|
||||
hasInitialFetch.current = true;
|
||||
// Prevent duplicate fetches in React StrictMode (development double-mounting)
|
||||
if (hasInitialFetch.current) return;
|
||||
hasInitialFetch.current = true;
|
||||
|
||||
// Performance: eager-set sessionTenantId if we already know it from TenantContext.
|
||||
// This lets dependent effects kick off fetchEvents/analysis/vulns/config in parallel
|
||||
// with fetchSessionDetails instead of waiting for its roundtrip — eliminates a waterfall.
|
||||
// Global Admins in all-tenant view fall through with null and keep the old behavior.
|
||||
const knownTenantId = resolveEffectiveTenantId();
|
||||
if (knownTenantId && isGuid(knownTenantId)) {
|
||||
setSessionTenantId(knownTenantId);
|
||||
}
|
||||
// Performance: eager-set sessionTenantId if we already know it from TenantContext.
|
||||
// This lets dependent effects kick off fetchEvents/analysis/vulns/config in parallel
|
||||
// with fetchSessionDetails instead of waiting for its roundtrip — eliminates a waterfall.
|
||||
// Global Admins in all-tenant view fall through with null and keep the old behavior.
|
||||
const knownTenantId = resolveEffectiveTenantId();
|
||||
if (knownTenantId && isGuid(knownTenantId)) {
|
||||
setSessionTenantId(knownTenantId);
|
||||
}
|
||||
|
||||
fetchSessionDetails();
|
||||
await fetchSessionDetails();
|
||||
};
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, tenantId, globalAdminMode, tenantIdOverride, user]);
|
||||
|
||||
|
||||
@@ -114,6 +114,9 @@ export function useSessionEvents({
|
||||
// Deduplication: track in-flight fetchEvents to avoid concurrent calls
|
||||
const fetchEventsInFlight = useRef(false);
|
||||
const fetchEventsQueued = useRef(false);
|
||||
// The queued follow-up re-invokes fetchEvents through this ref: a memoized callback
|
||||
// that calls itself directly cannot be memoized by the React compiler.
|
||||
const fetchEventsRef = useRef<() => Promise<void>>(async () => {});
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
// Deduplication: if a fetch is already in flight, queue one follow-up instead of
|
||||
@@ -232,11 +235,15 @@ export function useSessionEvents({
|
||||
// If another fetch was requested while we were in flight, run it now
|
||||
if (fetchEventsQueued.current) {
|
||||
fetchEventsQueued.current = false;
|
||||
fetchEvents();
|
||||
void fetchEventsRef.current();
|
||||
}
|
||||
}
|
||||
}, [sessionId, resolveEffectiveTenantId, sessionRef, fetchSessionDetails, setLoading, getAccessToken, addNotification]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEventsRef.current = fetchEvents;
|
||||
}, [fetchEvents]);
|
||||
|
||||
const scheduleFetchEvents = useCallback((delayMs = 300) => {
|
||||
if (eventRefreshTimeoutRef.current) {
|
||||
clearTimeout(eventRefreshTimeoutRef.current);
|
||||
@@ -249,7 +256,10 @@ export function useSessionEvents({
|
||||
// Fetch events when we have the session's tenant ID
|
||||
useEffect(() => {
|
||||
if (sessionTenantId && sessionId) {
|
||||
fetchEvents();
|
||||
const run = async () => {
|
||||
await fetchEvents();
|
||||
};
|
||||
void run();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionTenantId, sessionId]);
|
||||
|
||||
@@ -39,10 +39,9 @@ export function useDecisionGraph({
|
||||
if (!sessionId) return;
|
||||
let aborted = false;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = api.inspector.decisionGraph(sessionId, tenantId);
|
||||
const response = await authenticatedFetch(url, getAccessToken);
|
||||
@@ -63,7 +62,8 @@ export function useDecisionGraph({
|
||||
} finally {
|
||||
if (!aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
|
||||
+5
-5
@@ -44,10 +44,9 @@ export function useSessionAnchorEvents({
|
||||
if (!sessionId) return;
|
||||
let aborted = false;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = api.sessions.events(sessionId, tenantId);
|
||||
const response = await authenticatedFetch(url, getAccessToken);
|
||||
@@ -68,7 +67,8 @@ export function useSessionAnchorEvents({
|
||||
} finally {
|
||||
if (!aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
|
||||
@@ -44,10 +44,9 @@ export function useSessionSignals({
|
||||
if (!sessionId) return;
|
||||
let aborted = false;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = api.inspector.signals(sessionId, { tenantId, maxResults });
|
||||
const response = await authenticatedFetch(url, getAccessToken);
|
||||
@@ -69,7 +68,8 @@ export function useSessionSignals({
|
||||
} finally {
|
||||
if (!aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
|
||||
@@ -76,7 +76,9 @@ function SessionDetailContent() {
|
||||
const [phaseTimelineExpanded, setPhaseTimelineExpanded] = useState(true);
|
||||
const [perfExpanded, setPerfExpanded] = useState(true);
|
||||
const [timelineExpanded, setTimelineExpanded] = useState(true);
|
||||
const [expandedPhases, setExpandedPhases] = useState<Set<string>>(new Set());
|
||||
// Inverted expansion model: collapsed phases are stored, expansion is derived. A phase
|
||||
// that newly appears is expanded by default without any effect-driven state sync.
|
||||
const [collapsedPhases, setCollapsedPhases] = useState<Set<string>>(new Set());
|
||||
|
||||
const { adminMode, globalAdminMode } = useAdminMode();
|
||||
|
||||
@@ -133,12 +135,12 @@ function SessionDetailContent() {
|
||||
const sessionStatusForAttribution = session?.status;
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (sessionStatusForAttribution !== "Succeeded" && sessionStatusForAttribution !== "Failed") {
|
||||
setTimeBreakdown(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const run = async () => {
|
||||
if (sessionStatusForAttribution !== "Succeeded" && sessionStatusForAttribution !== "Failed") {
|
||||
setTimeBreakdown(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const effectiveTenantId = sessionTenantId || tenantIdOverride || undefined;
|
||||
const response = await authenticatedFetch(
|
||||
@@ -149,7 +151,8 @@ function SessionDetailContent() {
|
||||
} catch {
|
||||
// fail-soft: no lane
|
||||
}
|
||||
})();
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, sessionStatusForAttribution, sessionTenantId]);
|
||||
@@ -199,30 +202,23 @@ function SessionDetailContent() {
|
||||
userEnrollGrouped,
|
||||
} = derived;
|
||||
|
||||
// Auto-expand new phases as they appear (keeps existing expanded/collapsed state).
|
||||
// For WhiteGlove sessions we use prefixed keys (pre-X, user-X) to avoid collisions.
|
||||
useEffect(() => {
|
||||
setExpandedPhases(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
let hasChanges = false;
|
||||
|
||||
const allPhases = isWhiteGloveSession
|
||||
// All phase keys currently present. For WhiteGlove sessions we use prefixed keys
|
||||
// (pre-X, user-X) to avoid collisions.
|
||||
const allPhaseKeys = useMemo(
|
||||
() =>
|
||||
isWhiteGloveSession
|
||||
? [
|
||||
...preProvGrouped.orderedPhases.map(p => `pre-${p}`),
|
||||
...userEnrollGrouped.orderedPhases.map(p => `user-${p}`),
|
||||
]
|
||||
: orderedPhases;
|
||||
: orderedPhases,
|
||||
[orderedPhases, preProvGrouped.orderedPhases, userEnrollGrouped.orderedPhases, isWhiteGloveSession]
|
||||
);
|
||||
|
||||
for (const phase of allPhases) {
|
||||
if (!prev.has(phase)) {
|
||||
newExpanded.add(phase);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanges ? newExpanded : prev;
|
||||
});
|
||||
}, [orderedPhases, preProvGrouped.orderedPhases, userEnrollGrouped.orderedPhases, isWhiteGloveSession]);
|
||||
const expandedPhases = useMemo(
|
||||
() => new Set(allPhaseKeys.filter(p => !collapsedPhases.has(p))),
|
||||
[allPhaseKeys, collapsedPhases]
|
||||
);
|
||||
|
||||
const markAsFailed = () => setShowMarkFailedConfirm(true);
|
||||
const markAsSucceeded = () => setShowMarkSucceededConfirm(true);
|
||||
@@ -347,32 +343,23 @@ function SessionDetailContent() {
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
if (isWhiteGloveSession) {
|
||||
setExpandedPhases(new Set([
|
||||
...preProvGrouped.orderedPhases.map(p => `pre-${p}`),
|
||||
...userEnrollGrouped.orderedPhases.map(p => `user-${p}`),
|
||||
]));
|
||||
} else {
|
||||
setExpandedPhases(new Set(orderedPhases));
|
||||
}
|
||||
};
|
||||
const expandAll = () => setCollapsedPhases(new Set());
|
||||
|
||||
const collapseAll = () => setExpandedPhases(new Set());
|
||||
const collapseAll = () => setCollapsedPhases(new Set(allPhaseKeys));
|
||||
|
||||
const scrollToPhase = (phaseName: string) => {
|
||||
const id = `phase-${phaseName.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
setExpandedPhases(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
setCollapsedPhases(prev => {
|
||||
const next = new Set(prev);
|
||||
if (isWhiteGloveSession) {
|
||||
newExpanded.add(`pre-${phaseName}`);
|
||||
newExpanded.add(`user-${phaseName}`);
|
||||
next.delete(`pre-${phaseName}`);
|
||||
next.delete(`user-${phaseName}`);
|
||||
} else {
|
||||
newExpanded.add(phaseName);
|
||||
next.delete(phaseName);
|
||||
}
|
||||
return newExpanded;
|
||||
return next;
|
||||
});
|
||||
setTimeout(() => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
@@ -385,25 +372,29 @@ function SessionDetailContent() {
|
||||
if (loading || events.length === 0) return;
|
||||
const hash = window.location.hash;
|
||||
if (!hash.startsWith("#event-")) return;
|
||||
// Expand all phases so the event element is in the DOM
|
||||
expandAll();
|
||||
// Deferred so the expansion setState runs in a timer callback, not the effect body;
|
||||
// the inner delay then lets React commit the expanded phases before the DOM lookup.
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("ring-2", "ring-amber-400", "ring-offset-1");
|
||||
setTimeout(() => el.classList.remove("ring-2", "ring-amber-400", "ring-offset-1"), 3000);
|
||||
}
|
||||
}, 100);
|
||||
// Expand all phases so the event element is in the DOM
|
||||
expandAll();
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("ring-2", "ring-amber-400", "ring-offset-1");
|
||||
setTimeout(() => el.classList.remove("ring-2", "ring-amber-400", "ring-offset-1"), 3000);
|
||||
}
|
||||
}, 100);
|
||||
}, 0);
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}, [loading, events.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [loading, events.length]);
|
||||
|
||||
const togglePhase = (phaseName: string) => {
|
||||
setExpandedPhases(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
if (newExpanded.has(phaseName)) newExpanded.delete(phaseName);
|
||||
else newExpanded.add(phaseName);
|
||||
return newExpanded;
|
||||
setCollapsedPhases(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(phaseName)) next.delete(phaseName);
|
||||
else next.add(phaseName);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -655,7 +655,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
useEffect(() => {
|
||||
if (!tenantId) return;
|
||||
if (!user?.isTenantAdmin && !user?.isGlobalAdmin) return;
|
||||
fetchAdmins();
|
||||
const run = async () => {
|
||||
await fetchAdmins();
|
||||
};
|
||||
void run();
|
||||
}, [tenantId, user?.isTenantAdmin, user?.isGlobalAdmin, fetchAdmins]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -685,7 +688,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// the per-tenant GA flag is the additive Community enable.
|
||||
const bootstrapAvailable = editionInfo.edition === "pro" || config?.bootstrapTokenEnabled;
|
||||
if (!tenantId || !bootstrapAvailable) return;
|
||||
fetchBootstrapSessions();
|
||||
const run = async () => {
|
||||
await fetchBootstrapSessions();
|
||||
};
|
||||
void run();
|
||||
}, [tenantId, editionInfo.edition, config?.bootstrapTokenEnabled, fetchBootstrapSessions]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -117,7 +117,10 @@ export default function HardwareRejectionInsights({
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const run = async () => {
|
||||
await fetchData();
|
||||
};
|
||||
void run();
|
||||
}, [fetchData]);
|
||||
|
||||
// Loading state
|
||||
|
||||
@@ -88,8 +88,10 @@ export default function McpUsersSection() {
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMcpUsers();
|
||||
fetchPlanTiers();
|
||||
const run = async () => {
|
||||
await Promise.all([fetchMcpUsers(), fetchPlanTiers()]);
|
||||
};
|
||||
void run();
|
||||
}, [fetchMcpUsers, fetchPlanTiers]);
|
||||
|
||||
const handlePolicyChange = useCallback(async (newPolicy: McpPolicy) => {
|
||||
|
||||
+4
-1
@@ -62,7 +62,10 @@ export default function NotRegisteredDevicesInsights({
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const run = async () => {
|
||||
await fetchData();
|
||||
};
|
||||
void run();
|
||||
}, [fetchData]);
|
||||
|
||||
// Loading state
|
||||
|
||||
+4
-1
@@ -63,7 +63,10 @@ export default function TpmIncompatibleDevicesInsights({
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const run = async () => {
|
||||
await fetchData();
|
||||
};
|
||||
void run();
|
||||
}, [fetchData]);
|
||||
|
||||
if (!data || data.aggregated.length === 0) {
|
||||
|
||||
@@ -76,7 +76,10 @@ export function SectionMcpUsage() {
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsage(dateRange);
|
||||
const run = async () => {
|
||||
await fetchUsage(dateRange);
|
||||
};
|
||||
void run();
|
||||
}, [fetchUsage, dateRange]);
|
||||
|
||||
// Aggregate records by date
|
||||
|
||||
+6
-1
@@ -63,7 +63,12 @@ export function SectionOptionalGraphCapabilities() {
|
||||
}
|
||||
}, [tenantId, getAccessToken]);
|
||||
|
||||
useEffect(() => { void fetchStatus(); }, [fetchStatus]);
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
await fetchStatus();
|
||||
};
|
||||
void run();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
@@ -176,7 +176,12 @@ export default function SlaPage() {
|
||||
}
|
||||
}, [effectiveTenantId, isGlobalOverride, months, getAccessToken, addNotification]);
|
||||
|
||||
useEffect(() => { fetchMetrics(); }, [fetchMetrics]);
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
await fetchMetrics();
|
||||
};
|
||||
void run();
|
||||
}, [fetchMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantId) trackEvent('sla_page_viewed', { months });
|
||||
|
||||
@@ -148,7 +148,10 @@ export default function UsageMetricsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveTenantId) return;
|
||||
fetchMetrics();
|
||||
const run = async () => {
|
||||
await fetchMetrics();
|
||||
};
|
||||
void run();
|
||||
}, [effectiveTenantId, fetchMetrics]);
|
||||
|
||||
const selectedTenantName = tenants.find(t => t.tenantId === selectedTenantId)?.domainName;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CollapseState } from "../hooks/useSidebarState";
|
||||
import { DefaultSectionIcon, BookOpenIcon, InformationCircleIcon, DocumentTextIcon, ShieldCheckIcon } from "../lib/sidebarIcons";
|
||||
import { DASHBOARD_ITEM, NAV_GROUPS, EXPANDABLE_NAV_GROUPS, REGULAR_USER_ITEMS, NavItem, NavGroup, ExpandableNavGroup } from "../lib/globalNavConfig";
|
||||
import { useAdminMode } from "../hooks/useAdminMode";
|
||||
import { useMediaQuery } from "../hooks/useMediaQuery";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
// Sidebar pixel widths
|
||||
@@ -40,14 +41,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
const pathname = rawPathname.length > 1 ? rawPathname.replace(/\/+$/, "") : rawPathname;
|
||||
|
||||
// Track desktop breakpoint
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(min-width: 768px)");
|
||||
setIsDesktop(mql.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}, []);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
// Global admin mode toggle — gates the (purple) Global Admin section, identical to a real GA.
|
||||
const { globalAdminMode } = useAdminMode();
|
||||
@@ -92,19 +86,18 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
return () => observer.disconnect();
|
||||
}, [pageSections, pageSectionsMode]);
|
||||
|
||||
// Route mode for page sections
|
||||
useEffect(() => {
|
||||
if (pageSectionsMode !== "route" || pageSections.length === 0) return;
|
||||
// Route mode: the active section is a pure function of the pathname — derived at
|
||||
// render instead of synced into state. Scroll-spy and manual scrollTo keep using the
|
||||
// state; the route match wins whenever it resolves.
|
||||
const routeActiveSectionId = useMemo(() => {
|
||||
if (pageSectionsMode !== "route" || pageSections.length === 0) return null;
|
||||
// Match by href first (more precise), fall back to last segment matching id
|
||||
const hrefMatch = pageSections.find((item) => item.href && pathname === item.href);
|
||||
if (hrefMatch) {
|
||||
setActiveSectionId(hrefMatch.id);
|
||||
} else {
|
||||
const segment = pathname.split("/").pop() ?? "";
|
||||
const match = pageSections.find((item) => item.id === segment);
|
||||
if (match) setActiveSectionId(match.id);
|
||||
}
|
||||
if (hrefMatch) return hrefMatch.id;
|
||||
const segment = pathname.split("/").pop() ?? "";
|
||||
return pageSections.find((item) => item.id === segment)?.id ?? null;
|
||||
}, [pathname, pageSections, pageSectionsMode]);
|
||||
const effectiveActiveSectionId = routeActiveSectionId ?? activeSectionId;
|
||||
|
||||
const scrollTo = useCallback((id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
@@ -138,21 +131,20 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
// Track which groups are expanded — default: group containing active item
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// Auto-expand the group of the active section
|
||||
useEffect(() => {
|
||||
if (!groupedSections || !activeSectionId) return;
|
||||
for (const group of groupedSections) {
|
||||
if (group.items.some((item) => item.id === activeSectionId)) {
|
||||
setExpandedGroups((prev) => {
|
||||
if (prev.has(group.name)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(group.name);
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
// Auto-expand the group of the active section — adjust-during-render (react.dev
|
||||
// "storing information from previous renders"): expands once when the active section
|
||||
// changes, while still letting the user collapse that group afterwards.
|
||||
const [prevActiveForGroups, setPrevActiveForGroups] = useState("");
|
||||
if (prevActiveForGroups !== effectiveActiveSectionId) {
|
||||
setPrevActiveForGroups(effectiveActiveSectionId);
|
||||
if (groupedSections && effectiveActiveSectionId) {
|
||||
const activeGroup = groupedSections.find((group) =>
|
||||
group.items.some((item) => item.id === effectiveActiveSectionId));
|
||||
if (activeGroup && !expandedGroups.has(activeGroup.name)) {
|
||||
setExpandedGroups(new Set(expandedGroups).add(activeGroup.name));
|
||||
}
|
||||
}
|
||||
}, [activeSectionId, groupedSections]);
|
||||
}
|
||||
|
||||
const toggleGroup = useCallback((groupName: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
@@ -246,33 +238,34 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAdminOrOperator, hasGlobalScope, isGlobalAdmin, globalAdminMode, hasMcpAccess, isAdminLike, canManageBootstrapTokens, bootstrapTokenEnabled, unrestrictedModeEnabled]);
|
||||
|
||||
// Auto-expand the group containing the current pathname. Depends on
|
||||
// visibleExpandableGroups too: on a hard load (deep link, F5, post-deploy
|
||||
// chunk-recovery reload) this effect first runs while MSAL is still
|
||||
// resolving and the groups array is empty — without the dep it would never
|
||||
// re-run once auth lands, leaving the active group collapsed.
|
||||
useEffect(() => {
|
||||
for (const group of visibleExpandableGroups) {
|
||||
// Auto-expand the group containing the current pathname — adjust-during-render on
|
||||
// (pathname, visibleExpandableGroups). The groups identity matters too: on a hard
|
||||
// load (deep link, F5, post-deploy chunk-recovery reload) the first renders happen
|
||||
// while MSAL is still resolving and the groups array is empty — without re-running
|
||||
// once auth lands, the active group would stay collapsed.
|
||||
const [prevAutoExpand, setPrevAutoExpand] = useState<{
|
||||
path: string;
|
||||
groups: typeof visibleExpandableGroups;
|
||||
} | null>(null);
|
||||
if (prevAutoExpand?.path !== pathname || prevAutoExpand?.groups !== visibleExpandableGroups) {
|
||||
setPrevAutoExpand({ path: pathname, groups: visibleExpandableGroups });
|
||||
outer: for (const group of visibleExpandableGroups) {
|
||||
for (const item of group.items) {
|
||||
if (item.items.some((sub) => pathname === sub.href || pathname.startsWith(sub.href + "/"))) {
|
||||
// Auto-expand the category if collapsed
|
||||
setCollapsedCategories((prev) => {
|
||||
if (!prev.has(group.id)) return prev;
|
||||
const next = new Set(prev);
|
||||
if (collapsedCategories.has(group.id)) {
|
||||
const next = new Set(collapsedCategories);
|
||||
next.delete(group.id);
|
||||
return next;
|
||||
});
|
||||
setExpandedGroups((prev) => {
|
||||
if (prev.has(item.id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(item.id);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
setCollapsedCategories(next);
|
||||
}
|
||||
if (!expandedGroups.has(item.id)) {
|
||||
setExpandedGroups(new Set(expandedGroups).add(item.id));
|
||||
}
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [pathname, visibleExpandableGroups]);
|
||||
}
|
||||
|
||||
// Landing page: never show sidebar
|
||||
if (pathname === "/") {
|
||||
@@ -359,7 +352,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
|
||||
// --- Render a page section item ---
|
||||
const renderSectionItem = (item: PageSectionItem) => {
|
||||
const active = activeSectionId === item.id;
|
||||
const active = effectiveActiveSectionId === item.id;
|
||||
const base = `flex items-center gap-2.5 rounded-md text-sm transition-colors ${itemClass(active)}`;
|
||||
|
||||
if (collapseState === "icons") {
|
||||
@@ -647,7 +640,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
<ul className="space-y-0.5">
|
||||
{groupedSections.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.name);
|
||||
const groupHasActive = group.items.some((item) => item.id === activeSectionId);
|
||||
const groupHasActive = group.items.some((item) => item.id === effectiveActiveSectionId);
|
||||
const firstHref = group.items[0]?.href;
|
||||
|
||||
if (collapseState === "icons" && !isMobile) {
|
||||
|
||||
@@ -187,20 +187,27 @@ export default function InstallProgress({ events, summaryStats }: InstallProgres
|
||||
|
||||
function InstallItemRow({ item }: { item: InstallItem }) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [elapsedMs, setElapsedMs] = useState<number | null>(null);
|
||||
// Live 1s timer while this app is installing. The tick state carries the
|
||||
// startedAt it was computed from so the read below can mask stale values from
|
||||
// a previous activation — the effect only starts/stops the interval; the
|
||||
// "inactive → null" reset is derived at read instead of written back as state.
|
||||
const [liveElapsed, setLiveElapsed] = useState<{ startedAt: string; elapsedMs: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (item.state !== "Installing" || !item.startedAt) {
|
||||
setElapsedMs(null);
|
||||
return;
|
||||
}
|
||||
const startTime = new Date(item.startedAt).getTime();
|
||||
const tick = () => setElapsedMs(Date.now() - startTime);
|
||||
if (item.state !== "Installing" || !item.startedAt) return;
|
||||
const startedAt = item.startedAt;
|
||||
const startTime = new Date(startedAt).getTime();
|
||||
const tick = () => setLiveElapsed({ startedAt, elapsedMs: Date.now() - startTime });
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [item.state, item.startedAt]);
|
||||
|
||||
const elapsedMs =
|
||||
item.state === "Installing" && item.startedAt && liveElapsed?.startedAt === item.startedAt
|
||||
? liveElapsed.elapsedMs
|
||||
: null;
|
||||
|
||||
const sourcePill = SOURCE_PILLS[item.source];
|
||||
|
||||
// Session 080edee9 follow-up — only the genuine "no HRESULT available" timeout
|
||||
|
||||
@@ -31,12 +31,6 @@ export default function Navbar() {
|
||||
const [showOverflow, setShowOverflow] = useState(false);
|
||||
const [overflowSubmenu, setOverflowSubmenu] = useState<'help' | 'settings' | null>(null);
|
||||
const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
|
||||
const [previewMode, setPreviewMode] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('previewMode') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -44,37 +38,6 @@ export default function Navbar() {
|
||||
const helpRef = useRef<HTMLDivElement>(null);
|
||||
const overflowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('previewMode', previewMode.toString());
|
||||
window.dispatchEvent(new Event('localStorageChange'));
|
||||
}
|
||||
}, [previewMode]);
|
||||
|
||||
// Seed preview mode from ?preview=1 URL parameter (one-shot)
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const previewParam = params.get('preview');
|
||||
if (previewParam === '1' || previewParam === 'true') {
|
||||
setPreviewMode(true);
|
||||
// Clean up URL
|
||||
params.delete('preview');
|
||||
const cleanUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
} else if (previewParam === '0' || previewParam === 'false') {
|
||||
setPreviewMode(false);
|
||||
params.delete('preview');
|
||||
const cleanUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
|
||||
type Step =
|
||||
| { kind: "user"; text: string }
|
||||
@@ -45,16 +46,14 @@ const RESTART_PAUSE_MS = 7000;
|
||||
export function McpTerminalDemo() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
// Reduced motion shows the finished transcript immediately — derived, not seeded.
|
||||
const effectiveStepIndex = reduced ? SCRIPT.length : stepIndex;
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setReduced(true);
|
||||
setStepIndex(SCRIPT.length);
|
||||
return;
|
||||
}
|
||||
if (reduced) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
@@ -68,7 +67,7 @@ export function McpTerminalDemo() {
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [reduced]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started || reduced) return;
|
||||
@@ -92,11 +91,11 @@ export function McpTerminalDemo() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [started, reduced, stepIndex, charCount]);
|
||||
|
||||
const finished = stepIndex >= SCRIPT.length;
|
||||
const finished = effectiveStepIndex >= SCRIPT.length;
|
||||
|
||||
const renderStep = (step: Step, index: number) => {
|
||||
if (index > stepIndex) return null;
|
||||
const isCurrent = index === stepIndex;
|
||||
if (index > effectiveStepIndex) return null;
|
||||
const isCurrent = index === effectiveStepIndex;
|
||||
|
||||
switch (step.kind) {
|
||||
case "user": {
|
||||
|
||||
@@ -241,14 +241,6 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
const [isActivationPending, setActivationPending] = useState(false);
|
||||
const [activationMessage, setActivationMessage] = useState('');
|
||||
|
||||
// Handle SSR - if we're on the server, MSAL won't initialize
|
||||
// Set loading to false immediately on mount in browser
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetches user info from backend API
|
||||
*/
|
||||
|
||||
@@ -25,6 +25,9 @@ interface GlobalNotificationContextType {
|
||||
|
||||
const GlobalNotificationContext = createContext<GlobalNotificationContextType | undefined>(undefined);
|
||||
|
||||
// Stable identity for the scope-masked empty list.
|
||||
const EMPTY_NOTIFICATIONS: GlobalNotification[] = [];
|
||||
|
||||
export function GlobalNotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user, hasGlobalScope, getAccessToken } = useAuth();
|
||||
const { connection, isConnected, joinGroup, leaveGroup } = useSignalR();
|
||||
@@ -59,14 +62,15 @@ export function GlobalNotificationProvider({ children }: { children: React.React
|
||||
}, [hasGlobalScope, getAccessToken]);
|
||||
|
||||
// Initial state hydration. Re-fetched on SignalR reconnect to recover any deltas
|
||||
// pushed during the disconnect window.
|
||||
// pushed during the disconnect window. Without scope nothing is fetched — the
|
||||
// provider value below masks the list to empty instead of clearing state here.
|
||||
useEffect(() => {
|
||||
if (!hasGlobalScope) {
|
||||
setNotifications([]);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
fetchNotifications().finally(() => setIsLoading(false));
|
||||
if (!hasGlobalScope) return;
|
||||
const run = async () => {
|
||||
setIsLoading(true);
|
||||
await fetchNotifications().finally(() => setIsLoading(false));
|
||||
};
|
||||
void run();
|
||||
}, [hasGlobalScope, fetchNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -155,10 +159,15 @@ export function GlobalNotificationProvider({ children }: { children: React.React
|
||||
}
|
||||
}, [canManageGlobal, getAccessToken]);
|
||||
|
||||
const unreadCount = notifications.length;
|
||||
// Without platform scope consumers see an empty list — derived here rather than
|
||||
// clearing state, so a scope re-grant simply re-exposes the refetched data.
|
||||
const visibleNotifications = hasGlobalScope ? notifications : EMPTY_NOTIFICATIONS;
|
||||
const unreadCount = visibleNotifications.length;
|
||||
|
||||
return (
|
||||
<GlobalNotificationContext.Provider value={{ notifications, unreadCount, dismissNotification, dismissAll, isLoading }}>
|
||||
<GlobalNotificationContext.Provider
|
||||
value={{ notifications: visibleNotifications, unreadCount, dismissNotification, dismissAll, isLoading }}
|
||||
>
|
||||
{children}
|
||||
</GlobalNotificationContext.Provider>
|
||||
);
|
||||
|
||||
@@ -39,23 +39,19 @@ export function TenantProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update tenant ID from authenticated user
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated && user) {
|
||||
// Use tenant ID from authenticated user's token
|
||||
// This is the APPLICATION tenant ID, not the Azure AD tenant ID
|
||||
if (user.tenantId && user.tenantId !== tenantId) {
|
||||
console.log(`[TenantContext] Setting application tenant ID from auth: ${user.tenantId}`);
|
||||
setTenantId(user.tenantId);
|
||||
}
|
||||
} else if (!isLoading && !isAuthenticated) {
|
||||
// Not authenticated - clear tenant ID
|
||||
if (tenantId !== '') {
|
||||
console.log(`[TenantContext] User not authenticated, clearing tenant ID`);
|
||||
setTenantId('');
|
||||
}
|
||||
}
|
||||
}, [user, isAuthenticated, isLoading, tenantId]);
|
||||
// Mirror the tenant ID from the authenticated user — adjust-during-render instead of
|
||||
// an effect. This is the APPLICATION tenant ID, not the Azure AD tenant ID. While auth
|
||||
// is still loading the previous value is deliberately RETAINED (no flicker to '' during
|
||||
// token refresh); it only clears on a settled signed-out state.
|
||||
const authTenantId =
|
||||
!isLoading && isAuthenticated && user?.tenantId
|
||||
? user.tenantId
|
||||
: !isLoading && !isAuthenticated
|
||||
? ''
|
||||
: null; // null = auth still resolving, keep the current value
|
||||
if (authTenantId !== null && authTenantId !== tenantId) {
|
||||
setTenantId(authTenantId);
|
||||
}
|
||||
|
||||
return (
|
||||
<TenantContext.Provider value={{ tenantId, setTenantId }}>
|
||||
|
||||
@@ -26,6 +26,9 @@ interface TenantNotificationContextType {
|
||||
|
||||
const TenantNotificationContext = createContext<TenantNotificationContextType | undefined>(undefined);
|
||||
|
||||
// Stable identity for the rights-masked empty list.
|
||||
const EMPTY_TENANT_NOTIFICATIONS: TenantNotification[] = [];
|
||||
|
||||
export function TenantNotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const { connection, isConnected, joinGroup, leaveGroup } = useSignalR();
|
||||
@@ -62,14 +65,16 @@ export function TenantNotificationProvider({ children }: { children: React.React
|
||||
}, [canFetchNotifications, getAccessToken]);
|
||||
|
||||
// Initial state hydration. Runs once on mount and again after every SignalR reconnect to
|
||||
// recover any deltas that were emitted while the SignalR connection was down.
|
||||
// recover any deltas that were emitted while the SignalR connection was down. Without
|
||||
// fetch rights nothing is fetched — the provider value below masks the list to empty
|
||||
// instead of clearing state here.
|
||||
useEffect(() => {
|
||||
if (!canFetchNotifications) {
|
||||
setTenantNotifications([]);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
fetchNotifications().finally(() => setIsLoading(false));
|
||||
if (!canFetchNotifications) return;
|
||||
const run = async () => {
|
||||
setIsLoading(true);
|
||||
await fetchNotifications().finally(() => setIsLoading(false));
|
||||
};
|
||||
void run();
|
||||
}, [canFetchNotifications, fetchNotifications]);
|
||||
|
||||
// Re-fetch on SignalR reconnect — covers the deltas pushed during the disconnect window.
|
||||
@@ -156,11 +161,16 @@ export function TenantNotificationProvider({ children }: { children: React.React
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
const tenantUnreadCount = tenantNotifications.length;
|
||||
// Without fetch rights consumers see an empty list — derived here rather than
|
||||
// clearing state, so a rights re-grant simply re-exposes the refetched data.
|
||||
const visibleTenantNotifications = canFetchNotifications
|
||||
? tenantNotifications
|
||||
: EMPTY_TENANT_NOTIFICATIONS;
|
||||
const tenantUnreadCount = visibleTenantNotifications.length;
|
||||
|
||||
return (
|
||||
<TenantNotificationContext.Provider value={{
|
||||
tenantNotifications,
|
||||
tenantNotifications: visibleTenantNotifications,
|
||||
tenantUnreadCount,
|
||||
dismissTenantNotification,
|
||||
dismissAllTenant,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
|
||||
import {
|
||||
Theme,
|
||||
getServerThemeSnapshot,
|
||||
getThemeSnapshot,
|
||||
setStoredTheme,
|
||||
subscribeTheme,
|
||||
} from "@/lib/themeStore";
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
@@ -15,48 +20,18 @@ const ThemeContext = createContext<ThemeContextType>({
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("light");
|
||||
// localStorage preference (falling back to the OS preference) read as an external
|
||||
// store: the prerendered HTML hydrates light and re-renders with the real theme,
|
||||
// OS-level changes stream in via the store subscription.
|
||||
const theme = useSyncExternalStore(subscribeTheme, getThemeSnapshot, getServerThemeSnapshot);
|
||||
|
||||
// Initialize from localStorage on mount; fall back to system preference
|
||||
// Apply class to <html>
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("theme") as Theme | null;
|
||||
if (stored === "dark" || stored === "light") {
|
||||
setTheme(stored);
|
||||
} else {
|
||||
// No stored preference — use system preference
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setTheme(prefersDark ? "dark" : "light");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Listen for OS-level changes, but only when the user hasn't set a manual preference
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem("theme")) {
|
||||
setTheme(e.matches ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
|
||||
// Apply class to <html> and persist manual preference
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => {
|
||||
const next = prev === "dark" ? "light" : "dark";
|
||||
localStorage.setItem("theme", next);
|
||||
return next;
|
||||
});
|
||||
setStoredTheme(theme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Flat ESLint config (Next 16 removed `next lint`; run via `npm run lint`).
|
||||
// CI gates on this: errors fail the web job, warnings are ratcheted via
|
||||
// --max-warnings (see .github/workflows/ci.yml) — lower the cap when you fix
|
||||
// warnings, never raise it.
|
||||
// CI gates on this: errors fail the web job, and the codebase is warning-clean —
|
||||
// CI runs with --max-warnings 0 (see .github/workflows/ci.yml), so any new
|
||||
// warning fails the build. Fix findings; never raise the cap.
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
@@ -20,11 +20,12 @@ const config = [
|
||||
...nextTs,
|
||||
{
|
||||
// The React-Compiler-powered hooks rules (new in eslint-plugin-react-hooks
|
||||
// v6 via eslint-config-next 16) flag long-standing patterns whose cleanup
|
||||
// is a behavioral refactor per site (auth/SignalR/MSAL-adjacent code), not
|
||||
// a mechanical fix. Kept visible as warnings and burned down incrementally
|
||||
// under the CI warning ratchet; do NOT silence individual sites with
|
||||
// eslint-disable instead of fixing them.
|
||||
// v6 via eslint-config-next 16) are kept at "warn" so local dev output stays
|
||||
// readable, but the legacy backlog is fully burned down and CI's
|
||||
// --max-warnings 0 fails the build on any new finding. Fix new sites with
|
||||
// the established patterns (inner-async wrapper for fetch effects,
|
||||
// derive/adjust-during-render for state sync, useSyncExternalStore for
|
||||
// browser APIs); do NOT silence sites with eslint-disable.
|
||||
rules: {
|
||||
"react-hooks/set-state-in-effect": "warn",
|
||||
"react-hooks/refs": "warn",
|
||||
|
||||
@@ -21,3 +21,5 @@ export type {
|
||||
UseNotificationMessagesOptions,
|
||||
UseNotificationMessagesReturn,
|
||||
} from "./useNotificationMessages";
|
||||
|
||||
export { useMediaQuery } from "./useMediaQuery";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Subscribes to a CSS media query via useSyncExternalStore.
|
||||
*
|
||||
* The server snapshot is configurable (default false) so statically exported pages
|
||||
* hydrate against the markup they were prerendered with and re-render once the real
|
||||
* match is known — the same two-phase behavior as the classic mount-effect pattern,
|
||||
* without any effect-phase setState.
|
||||
*/
|
||||
export function useMediaQuery(query: string, serverSnapshot = false): boolean {
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
const mql = window.matchMedia(query);
|
||||
mql.addEventListener("change", onStoreChange);
|
||||
return () => mql.removeEventListener("change", onStoreChange);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
const getSnapshot = useCallback(() => window.matchMedia(query).matches, [query]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => serverSnapshot);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user