mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
web: progress-portal serial-knowledge model — lookup client, serial proof on events/join, reconnect re-proof, visible join denials
- useProgressSearch calls the server-side lookup; the client-side filter
over the tenant list is gone with the list itself.
- useProgressEvents passes the serial on every events read and refreshes
the session summary via the lookup (guarded by sessionId so a newer
re-enrollment of the same serial cannot swap the selected session).
- SignalRContext.joinGroup takes {serialNumber, onDenied}; the serial is
remembered per group and re-presented on the automatic post-reconnect
rejoin — without that, a roleless viewer's live updates would die on
the first network blip. A refused join is now visible (console.warn +
progress-page notification) instead of a silently frozen page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,7 @@ export function useProgressEvents({
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
api.progress.sessionEvents(session.sessionId, tenantId),
|
||||
api.progress.sessionEvents(session.sessionId, tenantId, session.serialNumber),
|
||||
getAccessToken,
|
||||
);
|
||||
if (response.ok) {
|
||||
@@ -121,21 +121,24 @@ export function useProgressEvents({
|
||||
const currentSession = sessionRef.current;
|
||||
if (!currentSession) return;
|
||||
try {
|
||||
const sessionsResponse = await authenticatedFetch(
|
||||
api.progress.sessions(tenantId),
|
||||
// Refresh the session summary via the serial lookup (the canonical serial from the
|
||||
// session row always matches exactly). Guard on sessionId: the lookup returns the
|
||||
// NEWEST session for the serial, which after a re-enrollment is a different session —
|
||||
// the page must keep showing the one the user selected.
|
||||
const lookupResponse = await authenticatedFetch(
|
||||
api.progress.lookup(tenantId, currentSession.serialNumber),
|
||||
getAccessToken,
|
||||
);
|
||||
if (sessionsResponse.ok) {
|
||||
const sessionsData = await sessionsResponse.json();
|
||||
const sessions: Session[] = sessionsData.sessions || [];
|
||||
const updated = sessions.find(
|
||||
(s) => s.sessionId === currentSession.sessionId,
|
||||
);
|
||||
if (updated) setSession(updated);
|
||||
if (lookupResponse.ok) {
|
||||
const lookupData = await lookupResponse.json();
|
||||
const updated: Session | null = lookupData.found ? lookupData.session : null;
|
||||
if (updated && updated.sessionId === currentSession.sessionId) {
|
||||
setSession(updated);
|
||||
}
|
||||
}
|
||||
|
||||
const eventsResponse = await authenticatedFetch(
|
||||
api.progress.sessionEvents(currentSession.sessionId, tenantId),
|
||||
api.progress.sessionEvents(currentSession.sessionId, tenantId, currentSession.serialNumber),
|
||||
getAccessToken,
|
||||
);
|
||||
if (eventsResponse.ok) {
|
||||
|
||||
@@ -37,7 +37,9 @@ export interface UseProgressSearchReturn {
|
||||
|
||||
/**
|
||||
* Owns the progress page's serial-number search lifecycle:
|
||||
* - fetches tenant-wide sessions list, finds best match by serial / device name
|
||||
* - resolves the session via the server-side lookup (exact serial/device-name match for
|
||||
* roleless end users — the serial IS the authorization proof; members keep fuzzy search
|
||||
* server-side; the tenant-wide list never reaches the browser)
|
||||
* - exposes `setSession` so real-time refetch can replace the selected session
|
||||
* - auto-collapses header on match, raises notFound on miss or error
|
||||
*/
|
||||
@@ -66,27 +68,13 @@ export function useProgressSearch({
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
api.progress.sessions(tenantId),
|
||||
api.progress.lookup(tenantId, serialInput.trim()),
|
||||
getAccessToken,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const sessions: Session[] = data.sessions || [];
|
||||
|
||||
const query = serialInput.trim().toLowerCase();
|
||||
const found = sessions
|
||||
.filter(
|
||||
(s) =>
|
||||
s.serialNumber.toLowerCase() === query ||
|
||||
s.serialNumber.toLowerCase().includes(query) ||
|
||||
s.deviceName?.toLowerCase().includes(query),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.startedAt).getTime() -
|
||||
new Date(a.startedAt).getTime(),
|
||||
)[0];
|
||||
const found: Session | null = data.found ? data.session : null;
|
||||
|
||||
if (found) {
|
||||
setSession(found);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Session } from "@/types";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
import type { JoinGroupOptions } from "@/contexts/SignalRContext";
|
||||
import type { NotificationType } from "@/contexts/NotificationContext";
|
||||
|
||||
interface SignalRApi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -10,24 +12,35 @@ interface SignalRApi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
|
||||
isConnected: boolean;
|
||||
joinGroup: (group: string) => Promise<void>;
|
||||
joinGroup: (group: string, options?: JoinGroupOptions) => Promise<void>;
|
||||
leaveGroup: (group: string) => Promise<void>;
|
||||
}
|
||||
|
||||
type AddNotification = (
|
||||
type: NotificationType,
|
||||
title: string,
|
||||
message: string,
|
||||
key?: string,
|
||||
href?: string,
|
||||
) => void;
|
||||
|
||||
interface UseProgressSignalRParams {
|
||||
session: Session | null;
|
||||
sessionRef: React.RefObject<Session | null>;
|
||||
signalR: SignalRApi;
|
||||
scheduleFetchEvents: (delayMs?: number) => void;
|
||||
addNotification: AddNotification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the progress page's SignalR integration:
|
||||
* - joins the session-specific group when a session is selected
|
||||
* (the tenant-wide broadcast group is member-role gated server-side; roleless
|
||||
* progress viewers would get a 403, and all signals for the selected session —
|
||||
* eventStream on ingest, newevents on admin mark-succeeded/failed — arrive on
|
||||
* the session group anyway)
|
||||
* - joins the session-specific group when a session is selected, presenting the device's
|
||||
* serial number as knowledge proof (roleless progress viewers are refused without it;
|
||||
* the tenant-wide broadcast group stays member-role gated server-side, and all signals
|
||||
* for the selected session — eventStream on ingest, newevents on admin
|
||||
* mark-succeeded/failed — arrive on the session group anyway)
|
||||
* - surfaces a refused join as a notification instead of a silently frozen page
|
||||
* (a swallowed join 403 is exactly how the c4dabeee regression stayed invisible)
|
||||
* - listens for newevents / newSession / eventStream → debounced refetch
|
||||
* - cleans up groups + handlers on unmount / session change
|
||||
*/
|
||||
@@ -36,6 +49,7 @@ export function useProgressSignalR({
|
||||
sessionRef,
|
||||
signalR,
|
||||
scheduleFetchEvents,
|
||||
addNotification,
|
||||
}: UseProgressSignalRParams): void {
|
||||
const { on, off, isConnected, joinGroup, leaveGroup } = signalR;
|
||||
|
||||
@@ -43,19 +57,38 @@ export function useProgressSignalR({
|
||||
// session, not object identity — a refetched session object must not rejoin.
|
||||
const sessionId = session?.sessionId;
|
||||
const sessionTenantId = session?.tenantId;
|
||||
const sessionSerial = session?.serialNumber;
|
||||
|
||||
// Ref-stabilized so an unstable addNotification identity can never churn the join effect
|
||||
// (which would leave + rejoin the group on every render).
|
||||
const addNotificationRef = useRef(addNotification);
|
||||
useEffect(() => {
|
||||
addNotificationRef.current = addNotification;
|
||||
}, [addNotification]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !sessionId || !sessionTenantId) return;
|
||||
|
||||
const sessionGroup = `session-${sessionTenantId}-${sessionId}`;
|
||||
console.log("[Progress] Joining session group:", sessionGroup);
|
||||
joinGroup(sessionGroup);
|
||||
joinGroup(sessionGroup, {
|
||||
serialNumber: sessionSerial,
|
||||
onDenied: (status) => {
|
||||
console.warn(`[Progress] Session group join denied (status ${status})`);
|
||||
addNotificationRef.current(
|
||||
"warning",
|
||||
"Live Updates Unavailable",
|
||||
"Real-time progress updates could not be enabled. The page will still refresh when you search again.",
|
||||
"progress-join-denied",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log("[Progress] Leaving session group:", sessionGroup);
|
||||
leaveGroup(sessionGroup);
|
||||
};
|
||||
}, [isConnected, sessionId, sessionTenantId, joinGroup, leaveGroup]);
|
||||
}, [isConnected, sessionId, sessionTenantId, sessionSerial, joinGroup, leaveGroup]);
|
||||
|
||||
useEffect(() => {
|
||||
const scheduleRefetch = (source: string, sessionId: string) => {
|
||||
|
||||
@@ -68,6 +68,7 @@ export default function ProgressPortalPage() {
|
||||
sessionRef,
|
||||
signalR,
|
||||
scheduleFetchEvents,
|
||||
addNotification,
|
||||
});
|
||||
|
||||
const { appSummary, currentDownload, currentInstall, installElapsedMs, overallProgress } =
|
||||
|
||||
@@ -12,6 +12,16 @@ import { useAuth } from './AuthContext';
|
||||
// consumer handlers keep their narrower parameter types without laundering here.
|
||||
type SignalRHandler = Parameters<signalR.HubConnection["on"]>[1];
|
||||
|
||||
// Session-group joins by roleless Progress-Portal users must present the device's serial number
|
||||
// (server-side knowledge proof). The serial is remembered per group so the automatic re-join
|
||||
// after a reconnect carries it too — without that, live updates would silently die on the first
|
||||
// network blip. onDenied surfaces a join the server refused (403); the context otherwise only
|
||||
// logs it, and a silent join failure historically looked like a frozen page.
|
||||
export interface JoinGroupOptions {
|
||||
serialNumber?: string;
|
||||
onDenied?: (status: number) => void;
|
||||
}
|
||||
|
||||
interface SignalRContextType {
|
||||
connection: signalR.HubConnection | null;
|
||||
connectionState: signalR.HubConnectionState;
|
||||
@@ -21,7 +31,7 @@ interface SignalRContextType {
|
||||
on: (eventName: SignalRMessageName, callback: SignalRHandler) => void;
|
||||
off: (eventName: SignalRMessageName, callback: SignalRHandler) => void;
|
||||
invoke: (methodName: string, ...args: unknown[]) => Promise<unknown>;
|
||||
joinGroup: (groupName: string) => Promise<void>;
|
||||
joinGroup: (groupName: string, options?: JoinGroupOptions) => Promise<void>;
|
||||
leaveGroup: (groupName: string) => Promise<void>;
|
||||
isConnected: boolean;
|
||||
joinedGroups: string[];
|
||||
@@ -50,6 +60,9 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
const [connectionState, setConnectionState] = useState<signalR.HubConnectionState>(signalR.HubConnectionState.Disconnected);
|
||||
const connectionRef = useRef<signalR.HubConnection | null>(null);
|
||||
const joinedGroupsRef = useRef<Set<string>>(new Set());
|
||||
// Serial-number proof per session group (see JoinGroupOptions). Kept across disconnects so the
|
||||
// auto-rejoin after a reconnect can re-present it; entries are removed on a confirmed leave.
|
||||
const joinSerialsRef = useRef<Map<string, string>>(new Map());
|
||||
const [joinedGroups, setJoinedGroups] = useState<string[]>([]);
|
||||
const retryCountRef = useRef(0);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -133,7 +146,13 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connectionId, groupName })
|
||||
// Re-present the stored serial proof for session groups — a roleless
|
||||
// Progress-Portal user's rejoin is refused without it.
|
||||
body: JSON.stringify({
|
||||
connectionId,
|
||||
groupName,
|
||||
serialNumber: joinSerialsRef.current.get(groupName),
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
@@ -246,7 +265,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
throw new Error('SignalR connection not established');
|
||||
};
|
||||
|
||||
const joinGroup = useCallback(async (groupName: string) => {
|
||||
const joinGroup = useCallback(async (groupName: string, options?: JoinGroupOptions) => {
|
||||
if (!connection || connectionState !== signalR.HubConnectionState.Connected) {
|
||||
return;
|
||||
}
|
||||
@@ -258,6 +277,9 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Add to Set immediately to prevent race conditions with multiple simultaneous calls
|
||||
joinedGroupsRef.current.add(groupName);
|
||||
if (options?.serialNumber) {
|
||||
joinSerialsRef.current.set(groupName, options.serialNumber);
|
||||
}
|
||||
syncJoinedGroups();
|
||||
|
||||
try {
|
||||
@@ -274,19 +296,29 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connectionId, groupName })
|
||||
body: JSON.stringify({
|
||||
connectionId,
|
||||
groupName,
|
||||
serialNumber: options?.serialNumber,
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Remove from Set if API call failed (so we can retry)
|
||||
// Remove from Set if API call failed (so we can retry). A refused join must be
|
||||
// VISIBLE: a silently swallowed 403 here historically left the page looking frozen
|
||||
// (no live updates, no error) — see the c4dabeee regression.
|
||||
console.warn(`[SignalR] Failed to join group ${groupName} (status ${response.status})`);
|
||||
joinedGroupsRef.current.delete(groupName);
|
||||
joinSerialsRef.current.delete(groupName);
|
||||
syncJoinedGroups();
|
||||
options?.onDenied?.(response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SignalR] Error joining group ${groupName}:`, error);
|
||||
// Remove from Set if API call failed (so we can retry)
|
||||
joinedGroupsRef.current.delete(groupName);
|
||||
joinSerialsRef.current.delete(groupName);
|
||||
syncJoinedGroups();
|
||||
}
|
||||
}, [connection, connectionState, getAccessToken, syncJoinedGroups]);
|
||||
@@ -325,6 +357,9 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
|
||||
// Re-add to Set if API call failed (so we can retry)
|
||||
joinedGroupsRef.current.add(groupName);
|
||||
syncJoinedGroups();
|
||||
} else {
|
||||
// Confirmed leave — drop the stored serial proof for this group.
|
||||
joinSerialsRef.current.delete(groupName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SignalR] Error leaving group ${groupName}:`, error);
|
||||
|
||||
@@ -389,11 +389,14 @@ export const api = {
|
||||
},
|
||||
|
||||
// ── Progress ──────────────────────────────────────────────────────────────
|
||||
// Serial-knowledge model: there is NO tenant-wide session list at this tier. The lookup
|
||||
// resolves at most one session server-side (roleless callers need the exact serial/device
|
||||
// name), and the events route re-presents the serial as proof on every read.
|
||||
progress: {
|
||||
sessions: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/progress/sessions${qs({ tenantId })}`,
|
||||
sessionEvents: (sessionId: string, tenantId: string) =>
|
||||
`${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId })}`,
|
||||
lookup: (tenantId: string, search: string) =>
|
||||
`${API_BASE_URL}/api/progress/sessions/lookup${qs({ tenantId, search })}`,
|
||||
sessionEvents: (sessionId: string, tenantId: string, serial: string) =>
|
||||
`${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId, serial })}`,
|
||||
},
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user