diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs
index 16da298d..10008463 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs
@@ -52,7 +52,8 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
{
try
{
- // 1. Kill-switch
+ // 1. Kill-switch — gate first so a disabled feature doesn't fan out
+ // four extra Table reads on every poll from every active user.
var adminConfig = await _adminConfigService.GetConfigurationAsync();
if (!adminConfig.FeedbackEnabled)
return await WriteJson(req, new { eligible = false });
@@ -61,24 +62,35 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
string tenantId = TenantHelper.GetTenantId(req);
string upn = TenantHelper.GetUserIdentifier(req);
+ // 3-6. The remaining checks each hit a different Table partition and
+ // depend only on (tenantId, upn) — fan them out in parallel so
+ // the endpoint is bounded by the slowest read instead of summing
+ // four sequential round-trips on Flex Consumption.
+ var roleTask = _tenantAdminsService.GetMemberRoleAsync(tenantId, upn);
+ var tenantConfigTask = _tenantConfigService.GetConfigurationAsync(tenantId);
+ var sessionsTask = _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: 1, continuation: null);
+ var feedbackTask = _configRepo.GetFeedbackEntryAsync(upn);
+
+ await Task.WhenAll(roleTask, tenantConfigTask, sessionsTask, feedbackTask);
+
// 3. Role check — only Admin + Operator
- var roleInfo = await _tenantAdminsService.GetMemberRoleAsync(tenantId, upn);
+ var roleInfo = roleTask.Result;
if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer)
return await WriteJson(req, new { eligible = false });
// 4. Tenant age check
- var tenantConfig = await _tenantConfigService.GetConfigurationAsync(tenantId);
+ var tenantConfig = tenantConfigTask.Result;
if (tenantConfig.OnboardedAt == null ||
(DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays)
return await WriteJson(req, new { eligible = false });
// 5. Sessions check — at least 1 session exists.
- var sessionPage = await _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: 1, continuation: null);
+ var sessionPage = sessionsTask.Result;
if (sessionPage.Items.Count == 0)
return await WriteJson(req, new { eligible = false });
// 6. Cooldown check
- var feedbackEntry = await _configRepo.GetFeedbackEntryAsync(upn);
+ var feedbackEntry = feedbackTask.Result;
if (feedbackEntry?.InteractedAt != null)
{
// Cooldown = 0 means single wave only
diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/TimingAllowOriginMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/TimingAllowOriginMiddleware.cs
new file mode 100644
index 00000000..09ac2c3f
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Middleware/TimingAllowOriginMiddleware.cs
@@ -0,0 +1,34 @@
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Middleware;
+
+namespace AutopilotMonitor.Functions.Middleware;
+
+///
+/// Adds the Timing-Allow-Origin: * response header so cross-origin
+/// callers (notably the portal) can read full Resource Timing details
+/// (transferSize, encodedBodySize, nextHopProtocol, ...)
+/// from the browser's performance.getEntriesByType('resource').
+/// Without this header those fields stay zero / "unknown" and our own
+/// real-user monitoring cannot see API payload sizes.
+///
+public class TimingAllowOriginMiddleware : IFunctionsWorkerMiddleware
+{
+ private const string HeaderName = "Timing-Allow-Origin";
+ private const string HeaderValue = "*";
+
+ public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
+ {
+ var httpContext = context.GetHttpContext();
+ if (httpContext != null)
+ {
+ httpContext.Response.OnStarting(() =>
+ {
+ if (!httpContext.Response.Headers.ContainsKey(HeaderName))
+ httpContext.Response.Headers[HeaderName] = HeaderValue;
+ return Task.CompletedTask;
+ });
+ }
+
+ await next(context);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index 44a5b166..bcde7718 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -28,6 +28,7 @@ builder.ConfigureFunctionsWebApplication();
// Order matters: request telemetry (wraps all) → correlation ID → global exception handler → JWT authentication (401) → policy enforcement (403)
builder.UseMiddleware();
builder.UseMiddleware();
+builder.UseMiddleware();
builder.UseMiddleware();
builder.UseMiddleware();
builder.UseMiddleware();
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts
index 1c510326..59a23f81 100644
--- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts
+++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts
@@ -65,8 +65,13 @@ export function useTenantSecurityConfig(
};
fetchTenantSecurityConfig();
+ // Depend on the user fields actually read above (primitives), not the
+ // whole user object — its identity flips when AuthContext swaps the
+ // prefetched user object for the freshly fetched one, which would
+ // otherwise refire this effect and produce a duplicate feature-flags
+ // request.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [tenantId, user]);
+ }, [tenantId, user?.isTenantAdmin, user?.isGlobalAdmin, user?.role]);
return serialValidationEnabled;
}
diff --git a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx
index eab53414..482c8990 100644
--- a/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx
+++ b/src/Web/autopilot-monitor-web/contexts/AuthContext.tsx
@@ -12,11 +12,13 @@ const msalInstance = new PublicClientApplication(msalConfig);
// Track MSAL initialization state so components can wait for it.
let msalReady = false;
-// Prefetch: store auth/me result fetched during MSAL init so fetchUserInfo
-// can use it immediately without an extra network round-trip.
+// Prefetch: store the in-flight auth/me Promise (not the resolved value) so
+// fetchUserInfo can await it instead of racing a duplicate fetch when the
+// prefetch hasn't settled yet. Resolves to the JSON body, or null if the
+// fetch failed / token acquisition failed.
// Runs as a fire-and-forget side-effect — MUST NOT block msalInitPromise,
// otherwise a cold backend would keep the UI on a white screen.
-let prefetchedAuthMe: Record | null = null;
+let prefetchedAuthMePromise: Promise | null> | null = null;
const msalInitPromise = msalInstance
.initialize()
@@ -24,12 +26,11 @@ const msalInitPromise = msalInstance
.then(() => {
msalReady = true;
- // Fire-and-forget: prefetch auth/me while React is still mounting.
- // Not awaited — if it finishes before fetchUserInfo runs, great;
- // if not, fetchUserInfo does its own fetch as before.
+ // Kick off prefetch while React is still mounting. fetchUserInfo will
+ // await the same Promise instead of firing its own fetch.
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
- msalInstance.acquireTokenSilent({
+ prefetchedAuthMePromise = msalInstance.acquireTokenSilent({
scopes: apiRequest.scopes,
account: accounts[0],
}).then(async (tokenResponse) => {
@@ -37,12 +38,8 @@ const msalInitPromise = msalInstance
headers: { 'Authorization': `Bearer ${tokenResponse.accessToken}` },
signal: AbortSignal.timeout(8000),
});
- if (res.ok) {
- prefetchedAuthMe = await res.json();
- }
- }).catch(() => {
- // Best-effort; fetchUserInfo will retry normally.
- });
+ return res.ok ? (await res.json()) as Record : null;
+ }).catch(() => null);
}
})
.catch((error) => {
@@ -141,12 +138,15 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
*/
const fetchUserInfo = useCallback(async (account: AccountInfo): Promise => {
try {
- // Use prefetched auth/me result if available (fetched during MSAL init).
- // Consume it exactly once to avoid stale data on subsequent calls.
- if (prefetchedAuthMe) {
- const data = prefetchedAuthMe;
- prefetchedAuthMe = null;
- return {
+ // Await the prefetch Promise if one is in flight (or already resolved).
+ // Consume exactly once so subsequent fetchUserInfo calls go through the
+ // normal path. If the prefetch returned null (e.g. token failed), fall
+ // through to a fresh fetch below rather than returning null.
+ if (prefetchedAuthMePromise) {
+ const pending = prefetchedAuthMePromise;
+ prefetchedAuthMePromise = null;
+ const data = await pending;
+ if (data) return {
displayName: (data.displayName as string) || account.name || '',
upn: (data.upn as string) || account.username || '',
tenantId: (data.tenantId as string) || account.tenantId || '',
diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx
index 3df73b8a..20fd621d 100644
--- a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx
+++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx
@@ -144,13 +144,15 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
}
};
- // Defer SignalR connection to avoid competing with critical API calls
- // (auth/me, sessions, tenant config) during initial page load.
- const deferTimer = setTimeout(startConnection, 2000);
+ // Start as soon as the user is authenticated. The previous 2s setTimeout
+ // was meant to avoid competing with the initial dashboard fetches, but
+ // the web origin is HTTP/2 and the API is on a different origin, so
+ // there is no real connection contention — the delay only pushed live
+ // updates ~2s into the dashboard load for no benefit.
+ startConnection();
// Cleanup only when provider unmounts (app closes)
return () => {
- clearTimeout(deferTimer);
// Clear any pending retry timeout to prevent reconnection after unmount
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);