mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
feat: add developer-mode startup and stack hydration timing (#1619)
* feat: add developer-mode startup and stack hydration timing Instrument boot-to-list and detail hydration with commit-aligned milestones, truthful request stages, and destination/gateway debug duration logs so performance work is guided by measurements. * fix: redact stack names and complete hydration request stages Stop logging stack identifiers in containers debug timing, and record state_dispatch (plus detail fetch spans) so copied reports match the advertised stage breakdown.
This commit is contained in:
@@ -58,6 +58,9 @@ import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surf
|
||||
import { BESPOKE_MOBILE_VIEWS } from './EditorLayout/mobile-treatments';
|
||||
import { CapabilityGate } from './CapabilityGate';
|
||||
import { HubOnlyGate } from './HubOnlyGate';
|
||||
import { HydrationTimingPanel } from './HydrationTimingPanel';
|
||||
import { useDeveloperMode } from '@/hooks/useDeveloperMode';
|
||||
import { markMilestone } from '@/lib/hydrationTiming';
|
||||
import type { SectionId } from './settings/types';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
|
||||
@@ -151,6 +154,14 @@ export default function EditorLayout() {
|
||||
const canOfferVolumeRemoval =
|
||||
activeNodeMeta?.capabilities.includes(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY) === true;
|
||||
|
||||
// One-shot boot milestone: the app shell has mounted. Developer mode gates the
|
||||
// hydration-timing overlay for the active node; it follows node switches.
|
||||
useEffect(() => {
|
||||
markMilestone('shell_committed');
|
||||
}, []);
|
||||
const developerMode = useDeveloperMode(activeNode?.id);
|
||||
const hydrationOverlay = developerMode ? <HydrationTimingPanel /> : null;
|
||||
|
||||
// Mirror activeNode.id in a ref so async handlers (e.g. CreateStackDialog's
|
||||
// post-create handoff) can detect a node switch that happened mid-flight.
|
||||
// Closure capture of activeNode would always match the value at handler-creation
|
||||
@@ -1120,6 +1131,7 @@ export default function EditorLayout() {
|
||||
/>
|
||||
{adoptDialogEl}
|
||||
{shellOverlaysEl}
|
||||
{hydrationOverlay}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1137,6 +1149,7 @@ export default function EditorLayout() {
|
||||
</div>
|
||||
{adoptDialogEl}
|
||||
{shellOverlaysEl}
|
||||
{hydrationOverlay}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { beginSpan, endSpan, markMilestone } from '@/lib/hydrationTiming';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from '../../dashboard/types';
|
||||
@@ -23,6 +24,10 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
onStateInvalidateRef.current = onStateInvalidate;
|
||||
const onImageUpdatesChangeRef = useRef(onImageUpdatesChange);
|
||||
onImageUpdatesChangeRef.current = onImageUpdatesChange;
|
||||
// One-shot: the notifications_ready milestone reflects the first local settle.
|
||||
// Its spans instrument only that first fetch so later polls do not pollute the
|
||||
// report.
|
||||
const notificationsReadyRef = useRef(false);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
@@ -31,18 +36,41 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
|
||||
// Skip offline nodes: polling a removed/unreachable node only yields 502s.
|
||||
const remoteNodes = currentNodes.filter(n => n.type === 'remote' && n.status !== 'offline');
|
||||
|
||||
const [localResult, ...remoteNodeResults] = await Promise.allSettled([
|
||||
apiFetch('/notifications', { localOnly: true } as Parameters<typeof apiFetch>[1]),
|
||||
...remoteNodes.map(n => fetchForNode('/notifications', n.id)),
|
||||
]);
|
||||
// Launch every request concurrently, but settle the local one first so the
|
||||
// notifications_ready milestone is not held back by the slowest remote.
|
||||
const localPromise = apiFetch('/notifications', { localOnly: true } as Parameters<typeof apiFetch>[1]);
|
||||
const remotePromises = remoteNodes.map(n => fetchForNode('/notifications', n.id));
|
||||
|
||||
const all: NotificationItem[] = [];
|
||||
|
||||
if (localResult.status === 'fulfilled' && localResult.value.ok) {
|
||||
const data = await localResult.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
|
||||
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
|
||||
const instrument = !notificationsReadyRef.current;
|
||||
const headersSpan = instrument ? beginSpan('fetch_headers', { background: true }) : null;
|
||||
let bodySpan: ReturnType<typeof beginSpan> | null = null;
|
||||
try {
|
||||
const localRes = await localPromise;
|
||||
if (headersSpan !== null) endSpan(headersSpan, { detail: { status: localRes.status } });
|
||||
if (localRes.ok) {
|
||||
bodySpan = instrument ? beginSpan('body_decode', { background: true }) : null;
|
||||
const data = await localRes.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
|
||||
if (bodySpan !== null) endSpan(bodySpan);
|
||||
bodySpan = null;
|
||||
const dispatchSpan = instrument ? beginSpan('state_dispatch', { background: true }) : null;
|
||||
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
|
||||
if (dispatchSpan !== null) endSpan(dispatchSpan);
|
||||
}
|
||||
} catch (e) {
|
||||
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
|
||||
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
|
||||
console.error('[Notifications] local fetch error:', e);
|
||||
} finally {
|
||||
if (!notificationsReadyRef.current) {
|
||||
notificationsReadyRef.current = true;
|
||||
markMilestone('notifications_ready');
|
||||
}
|
||||
}
|
||||
|
||||
// Remotes settle in the background; they never gate the milestone above.
|
||||
const remoteNodeResults = await Promise.allSettled(remotePromises);
|
||||
for (let i = 0; i < remoteNodes.length; i++) {
|
||||
const result = remoteNodeResults[i];
|
||||
if (result?.status === 'fulfilled' && result.value.ok) {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import { useRef, useCallback, useEffect, useState } from 'react';
|
||||
import { apiFetch, withDeploySession } from '@/lib/api';
|
||||
import {
|
||||
newAttemptId,
|
||||
abortAttempt,
|
||||
beginSpan,
|
||||
endSpan,
|
||||
flushPendingCommit,
|
||||
type PendingCommit,
|
||||
type SpanHandle,
|
||||
} from '@/lib/hydrationTiming';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { buildServiceUrl, openServiceUrl } from '@/lib/serviceUrl';
|
||||
import type { useEditorViewState } from './useEditorViewState';
|
||||
@@ -268,6 +277,17 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
// late responses never overwrite freshly-loaded state.
|
||||
const loadFileAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Hydration-timing: the current detail (loadFileCore) attempt, the file it is
|
||||
// loading, and the commits waiting for React to observe committed state.
|
||||
const detailAttemptRef = useRef<string | null>(null);
|
||||
const detailFileRef = useRef<string | null>(null);
|
||||
const detailVisiblePendingRef = useRef<PendingCommit | null>(null);
|
||||
const detailContainersPendingRef = useRef<PendingCommit | null>(null);
|
||||
const detailHydratedPendingRef = useRef<PendingCommit | null>(null);
|
||||
// Bumped when arming detail_visible so same-file reloads (selectedFile and
|
||||
// activeView unchanged) still re-run the commit effect.
|
||||
const [detailVisibleEpoch, setDetailVisibleEpoch] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (checkUpdatesIntervalRef.current !== null) {
|
||||
@@ -277,6 +297,28 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Commit-aligned detail milestones. Each fires once React has committed the
|
||||
// observed state for the owning attempt; commitMilestone no-ops for a
|
||||
// superseded (node switch) or aborted (re-load) attempt so an interrupted
|
||||
// load never records a success milestone.
|
||||
useEffect(() => {
|
||||
if (stackListState.selectedFile !== detailFileRef.current) return;
|
||||
if (navState.activeView !== 'editor') return;
|
||||
flushPendingCommit(detailVisiblePendingRef, 'detail_visible');
|
||||
}, [stackListState.selectedFile, navState.activeView, detailVisibleEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stackListState.selectedFile !== detailFileRef.current) return;
|
||||
flushPendingCommit(detailContainersPendingRef, 'detail_containers_ready');
|
||||
}, [editorState.containers, stackListState.selectedFile]);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for the load to settle so this reflects the fully hydrated detail.
|
||||
if (editorState.isFileLoading) return;
|
||||
if (stackListState.selectedFile !== detailFileRef.current) return;
|
||||
flushPendingCommit(detailHydratedPendingRef, 'detail_hydrated');
|
||||
}, [editorState.isFileLoading, stackListState.selectedFile, editorState.containers]);
|
||||
|
||||
const isAbortError = (err: unknown): boolean =>
|
||||
err instanceof Error && err.name === 'AbortError';
|
||||
|
||||
@@ -476,16 +518,47 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadContainerState = async (filename: string, signal?: AbortSignal) => {
|
||||
const loadContainerState = async (
|
||||
filename: string,
|
||||
signal?: AbortSignal,
|
||||
attemptId?: string,
|
||||
proxied?: boolean,
|
||||
): Promise<number> => {
|
||||
let headersSpan: SpanHandle | null = null;
|
||||
let bodySpan: SpanHandle | null = null;
|
||||
try {
|
||||
headersSpan = attemptId
|
||||
? beginSpan('fetch_headers', { attemptId, proxied })
|
||||
: null;
|
||||
const containersRes = await apiFetch(`/stacks/${filename}/containers`, { signal });
|
||||
if (signal?.aborted) return;
|
||||
const hopProxied = containersRes.headers.get('x-sencho-proxy') === '1' || proxied === true;
|
||||
if (headersSpan !== null) {
|
||||
endSpan(headersSpan, { proxied: hopProxied, detail: { status: containersRes.status } });
|
||||
headersSpan = null;
|
||||
}
|
||||
if (signal?.aborted) return 0;
|
||||
bodySpan = attemptId
|
||||
? beginSpan('body_decode', { attemptId, proxied: hopProxied })
|
||||
: null;
|
||||
const conts = await containersRes.json();
|
||||
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
||||
if (bodySpan !== null) {
|
||||
endSpan(bodySpan);
|
||||
bodySpan = null;
|
||||
}
|
||||
const list = Array.isArray(conts) ? conts : [];
|
||||
const dispatchSpan = attemptId
|
||||
? beginSpan('state_dispatch', { attemptId, proxied: hopProxied })
|
||||
: null;
|
||||
editorState.setContainers(list);
|
||||
if (dispatchSpan !== null) endSpan(dispatchSpan);
|
||||
return list.length;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) return;
|
||||
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
|
||||
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
|
||||
if (isAbortError(error)) return 0;
|
||||
console.error('Failed to load containers:', error);
|
||||
editorState.setContainers([]);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -520,29 +593,58 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
return { ok: false };
|
||||
}
|
||||
loadFileAbortRef.current?.abort();
|
||||
// Supersede the previous detail attempt so a late commit from an interrupted
|
||||
// load can never record a success milestone.
|
||||
if (detailAttemptRef.current) abortAttempt(detailAttemptRef.current);
|
||||
detailVisiblePendingRef.current = null;
|
||||
detailContainersPendingRef.current = null;
|
||||
detailHydratedPendingRef.current = null;
|
||||
const controller = new AbortController();
|
||||
loadFileAbortRef.current = controller;
|
||||
const { signal } = controller;
|
||||
const attemptId = newAttemptId();
|
||||
detailAttemptRef.current = attemptId;
|
||||
detailFileRef.current = filename;
|
||||
|
||||
editorState.setIsFileLoading(true);
|
||||
editorState.setIsEditing(false);
|
||||
editorState.setEditingCompose(false);
|
||||
editorState.setActiveTab('compose');
|
||||
let headersSpan: SpanHandle | null = null;
|
||||
let bodySpan: SpanHandle | null = null;
|
||||
try {
|
||||
headersSpan = beginSpan('fetch_headers', { attemptId });
|
||||
const res = await apiFetch(`/stacks/${filename}`, { signal });
|
||||
if (signal.aborted) return { ok: false };
|
||||
const proxied = res.headers.get('x-sencho-proxy') === '1';
|
||||
endSpan(headersSpan, { proxied, detail: { status: res.status } });
|
||||
headersSpan = null;
|
||||
if (signal.aborted) { abortAttempt(attemptId); return { ok: false }; }
|
||||
bodySpan = beginSpan('body_decode', { attemptId, proxied });
|
||||
const text = await res.text();
|
||||
if (signal.aborted) return { ok: false };
|
||||
endSpan(bodySpan);
|
||||
bodySpan = null;
|
||||
if (signal.aborted) { abortAttempt(attemptId); return { ok: false }; }
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load stack: ${res.status}`);
|
||||
}
|
||||
const dispatchSpan = beginSpan('state_dispatch', { attemptId, proxied });
|
||||
stackListState.setSelectedFile(filename);
|
||||
navState.setActiveView('editor');
|
||||
editorState.setContent(text || '');
|
||||
editorState.setOriginalContent(text || '');
|
||||
editorState.setComposeEtag(res.headers.get('etag'));
|
||||
endSpan(dispatchSpan);
|
||||
detailVisiblePendingRef.current = { attemptId, token: filename, proxied };
|
||||
setDetailVisibleEpoch((n) => n + 1);
|
||||
const envFiles = await loadEnvState(filename, signal);
|
||||
await loadContainerState(filename, signal);
|
||||
const containerCount = await loadContainerState(filename, signal, attemptId, proxied);
|
||||
if (!signal.aborted) {
|
||||
detailContainersPendingRef.current = {
|
||||
attemptId,
|
||||
token: `${filename}:${containerCount}`,
|
||||
proxied,
|
||||
};
|
||||
}
|
||||
await loadBackupState(filename, signal);
|
||||
// Post-load auto-edit evaluates permission for the loaded target, not
|
||||
// the previously selected stack (selectedFile was just updated above).
|
||||
@@ -551,9 +653,14 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
editorState.setActiveTab('compose');
|
||||
editorState.setIsEditing(true);
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
detailHydratedPendingRef.current = { attemptId, token: filename, proxied };
|
||||
}
|
||||
return { ok: true, envFiles };
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || signal.aborted) return { ok: false };
|
||||
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
|
||||
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
|
||||
if (isAbortError(error) || signal.aborted) { abortAttempt(attemptId); return { ok: false }; }
|
||||
console.error('Failed to load file:', error);
|
||||
toast.error(`Could not open "${filename.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`);
|
||||
stackListState.setSelectedFile(null);
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import {
|
||||
newAttemptId,
|
||||
abortAttempt,
|
||||
beginSpan,
|
||||
endSpan,
|
||||
flushPendingCommit,
|
||||
markMilestone,
|
||||
type SpanHandle,
|
||||
type PendingCommit,
|
||||
} from '@/lib/hydrationTiming';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useImageUpdates } from '@/hooks/useImageUpdates';
|
||||
@@ -85,6 +95,13 @@ export function useStackListState() {
|
||||
// state writes so a rapid node switch cannot leave a stale files/filesNodeId.
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
// Hydration-timing: the current foreground list attempt and the commits it is
|
||||
// waiting for React to observe. Only foreground loads arm these; background
|
||||
// refreshes still record diagnostic spans but do not re-commit the milestones.
|
||||
const listAttemptRef = useRef<string | null>(null);
|
||||
const listVisiblePendingRef = useRef<PendingCommit | null>(null);
|
||||
const listHydratedPendingRef = useRef<PendingCommit | null>(null);
|
||||
|
||||
// Per-stack terminal failure records driving the in-detail recovery panel.
|
||||
// In-memory only. Node scoping is enforced by the caller, which clears these
|
||||
// on active-node change (see EditorLayout's node-switch effect) so a repeated
|
||||
@@ -196,6 +213,20 @@ export function useStackListState() {
|
||||
const mySeq = ++fetchSeqRef.current;
|
||||
const stale = () => fetchSeqRef.current !== mySeq;
|
||||
|
||||
// Supersede any in-flight list attempt so a late commit from an interrupted
|
||||
// load cannot record list_visible / list_hydrated for a stale fetch.
|
||||
if (listAttemptRef.current) abortAttempt(listAttemptRef.current);
|
||||
listVisiblePendingRef.current = null;
|
||||
listHydratedPendingRef.current = null;
|
||||
|
||||
const attemptId = newAttemptId();
|
||||
listAttemptRef.current = attemptId;
|
||||
// True once the list itself is committed, so the shared catch below can tell
|
||||
// a list-fetch failure (nothing visible) from a status-path failure (list is
|
||||
// visible, hydration errored).
|
||||
let listSucceeded = false;
|
||||
let proxied = false;
|
||||
|
||||
if (!background) setIsLoading(true);
|
||||
setStacksLoadNodeId(fetchNodeId);
|
||||
if (!background || !hadSuccessfulListRef.current) {
|
||||
@@ -203,9 +234,13 @@ export function useStackListState() {
|
||||
setStacksLoadError(null);
|
||||
}
|
||||
|
||||
const headersSpan = beginSpan('fetch_headers', { attemptId, background });
|
||||
let bodySpan: SpanHandle | null = null;
|
||||
try {
|
||||
const res = await apiFetch('/stacks');
|
||||
if (stale()) return [];
|
||||
proxied = res.headers.get('x-sencho-proxy') === '1';
|
||||
endSpan(headersSpan, { proxied, detail: { status: res.status } });
|
||||
if (stale()) { abortAttempt(attemptId); return []; }
|
||||
if (!res.ok) {
|
||||
const message = `Could not load stacks (${res.status})`;
|
||||
if (background && hadSuccessfulListRef.current) {
|
||||
@@ -218,26 +253,51 @@ export function useStackListState() {
|
||||
setStacksLoadError(message);
|
||||
return [];
|
||||
}
|
||||
bodySpan = beginSpan('body_decode', { attemptId, background, proxied });
|
||||
const data = await res.json();
|
||||
endSpan(bodySpan);
|
||||
bodySpan = null;
|
||||
const fileList: string[] = Array.isArray(data) ? data : [];
|
||||
const listDispatch = beginSpan('state_dispatch', { attemptId, background, proxied });
|
||||
setFiles(fileList);
|
||||
setFilesNodeId(fetchNodeId);
|
||||
hadSuccessfulListRef.current = true;
|
||||
setStacksLoadStatus('success');
|
||||
setStacksLoadError(null);
|
||||
endSpan(listDispatch);
|
||||
listSucceeded = true;
|
||||
// Token folds node + count so an empty->empty commit still fires once per
|
||||
// attempt even when the committed `files` is referentially equal.
|
||||
const listToken = `${fetchNodeId}:${fileList.length}`;
|
||||
if (!background) {
|
||||
listVisiblePendingRef.current = { attemptId, token: listToken, proxied };
|
||||
}
|
||||
|
||||
// Fetch all stack statuses in a single bulk call. Only the current object
|
||||
// format can express `partial`; a node lacking the endpoint or returning
|
||||
// the legacy plain-string format is re-derived from per-stack containers
|
||||
// so a crashed container is not hidden behind a healthy sibling.
|
||||
const statusHeaders = beginSpan('fetch_headers', { attemptId, background, proxied });
|
||||
const statusRes = await apiFetch('/stacks/statuses');
|
||||
if (stale()) return fileList;
|
||||
const statusProxied = statusRes.headers.get('x-sencho-proxy') === '1' || proxied;
|
||||
endSpan(statusHeaders, { proxied: statusProxied, detail: { status: statusRes.status } });
|
||||
if (stale()) { abortAttempt(attemptId); return fileList; }
|
||||
let bulkStatuses: Record<string, StackRowStatus> = {};
|
||||
const bulkPorts: Record<string, number | undefined> = {};
|
||||
const bulkSelf: Record<string, boolean> = {};
|
||||
const bulkCounts: StackCounts = {};
|
||||
|
||||
const raw: unknown = statusRes.ok ? await statusRes.json() : null;
|
||||
let raw: unknown = null;
|
||||
if (statusRes.ok) {
|
||||
const statusBodySpan = beginSpan('body_decode', { attemptId, background, proxied: statusProxied });
|
||||
try {
|
||||
raw = await statusRes.json();
|
||||
endSpan(statusBodySpan);
|
||||
} catch (decodeErr) {
|
||||
endSpan(statusBodySpan, { outcome: 'error' });
|
||||
throw decodeErr;
|
||||
}
|
||||
}
|
||||
if (isBulkStatusObjectFormat(raw)) {
|
||||
for (const [key, val] of Object.entries(raw as Record<string, StackStatusInfo>)) {
|
||||
bulkStatuses[key] = val.status;
|
||||
@@ -250,6 +310,7 @@ export function useStackListState() {
|
||||
} else {
|
||||
bulkStatuses = await deriveStatusesFromContainers(fileList);
|
||||
}
|
||||
const statusDispatch = beginSpan('state_dispatch', { attemptId, background, proxied: statusProxied });
|
||||
setStackStatuses(prev => {
|
||||
const next: StackStatus = {};
|
||||
for (const file of fileList) {
|
||||
@@ -265,12 +326,24 @@ export function useStackListState() {
|
||||
});
|
||||
setStackSelfFlags(bulkSelf);
|
||||
setStackCounts(bulkCounts);
|
||||
endSpan(statusDispatch);
|
||||
refreshLabels();
|
||||
if (!background) {
|
||||
listHydratedPendingRef.current = { attemptId, token: listToken, proxied: statusProxied };
|
||||
}
|
||||
return fileList;
|
||||
} catch (error) {
|
||||
if (stale()) return [];
|
||||
// endSpan is a no-op when the span was already closed (or never opened).
|
||||
endSpan(headersSpan, { outcome: 'error' });
|
||||
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
|
||||
if (stale()) { abortAttempt(attemptId); return []; }
|
||||
console.error('Failed to refresh stacks:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to load stacks';
|
||||
// The list committed but hydrating its statuses threw: record the list
|
||||
// path as hydrated-with-error rather than leaving it hanging.
|
||||
if (listSucceeded && !background) {
|
||||
markMilestone('list_hydrated', { attemptId, outcome: 'error', proxied });
|
||||
}
|
||||
if (background && hadSuccessfulListRef.current) {
|
||||
setStacksLoadError(message);
|
||||
return files;
|
||||
@@ -290,6 +363,20 @@ export function useStackListState() {
|
||||
const refreshStacksRef = useRef(refreshStacks);
|
||||
useEffect(() => { refreshStacksRef.current = refreshStacks; });
|
||||
|
||||
// Commit-aligned list milestones: fire once React has actually committed the
|
||||
// file list (list_visible) and the statuses (list_hydrated) for the owning
|
||||
// attempt. commitMilestone no-ops for a superseded/aborted attempt, so a stale
|
||||
// load can never complete a session it no longer owns. Empty lists still fire
|
||||
// via the completion token.
|
||||
useEffect(() => {
|
||||
if (stacksLoadStatus !== 'success') return;
|
||||
flushPendingCommit(listVisiblePendingRef, 'list_visible');
|
||||
}, [files, filesNodeId, stacksLoadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
flushPendingCommit(listHydratedPendingRef, 'list_hydrated');
|
||||
}, [stackStatuses, filesNodeId]);
|
||||
|
||||
const handleScanStacks = async () => {
|
||||
if (isScanning) return;
|
||||
setIsScanning(true);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Copy, Timer, Trash2, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useHydrationTiming } from '@/hooks/useHydrationTiming';
|
||||
import { clearReport, getHydrationReport } from '@/lib/hydrationTiming';
|
||||
import type { HydrationOutcome } from '@/lib/hydrationTiming';
|
||||
|
||||
/** Format an elapsed duration compactly: seconds with one decimal at or above
|
||||
* 1s, whole milliseconds below. */
|
||||
function formatMs(ms: number): string {
|
||||
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
|
||||
}
|
||||
|
||||
function formatOffset(ms: number | null): string {
|
||||
return ms == null ? '-' : formatMs(ms);
|
||||
}
|
||||
|
||||
function outcomeDotClass(outcome: HydrationOutcome | undefined, critical: boolean): string {
|
||||
if (outcome === 'error') return 'bg-destructive';
|
||||
if (outcome === 'aborted' || outcome === 'superseded') return 'bg-muted-foreground';
|
||||
return critical ? 'bg-brand' : 'bg-success';
|
||||
}
|
||||
|
||||
const POSITION_CLASS =
|
||||
'fixed left-4 bottom-6 z-[100] max-md:left-3 max-md:right-3 max-md:bottom-[calc(var(--sn-mobile-tabbar-h)_+_env(safe-area-inset-bottom)_+_0.75rem)]';
|
||||
|
||||
/**
|
||||
* Developer-mode-only overlay for startup and stack-hydration timing.
|
||||
*
|
||||
* Mount this only when developer mode is on for the active node; it does not
|
||||
* gate itself. It shows a collapsed chip with the boot-to-`list_visible`
|
||||
* elapsed time, expanding to a phase table with copy/clear actions. It sits
|
||||
* below toasts and modals and never covers the mobile tab bar or safe area.
|
||||
*/
|
||||
export function HydrationTimingPanel() {
|
||||
const { listVisibleMs } = useHydrationTiming();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setExpanded(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [expanded]);
|
||||
|
||||
const chipLabel = listVisibleMs == null ? 'list …' : `list ${formatMs(listVisibleMs)}`;
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await copyToClipboard(JSON.stringify(getHydrationReport(), null, 2));
|
||||
toast.success('Hydration report copied.');
|
||||
} catch (e) {
|
||||
console.error('[HydrationTiming] copy failed:', e);
|
||||
toast.error('Could not copy the hydration report.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Hydration timing, ${chipLabel}`}
|
||||
data-testid="hydration-chip"
|
||||
onClick={() => setExpanded(true)}
|
||||
className={cn(
|
||||
POSITION_CLASS,
|
||||
'flex items-center gap-1.5 rounded-full border border-glass-border bg-popover/95 px-3 py-1.5 text-xs text-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] max-md:right-auto',
|
||||
)}
|
||||
>
|
||||
<Timer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="font-mono tabular-nums">{chipLabel}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Only build the (potentially large) report while the panel is open.
|
||||
const report = getHydrationReport();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Hydration timing"
|
||||
data-testid="hydration-panel"
|
||||
className={cn(
|
||||
POSITION_CLASS,
|
||||
'flex w-[360px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-glass-border bg-popover/95 text-xs text-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] max-md:w-auto',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-glass-border px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 font-medium">
|
||||
<Timer className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Hydration timing
|
||||
</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground">{chipLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto max-md:max-h-[40vh]">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead className="sticky top-0 bg-popover/95 text-[0.65rem] uppercase tracking-wide text-muted-foreground backdrop-blur-[10px]">
|
||||
<tr>
|
||||
<th className="px-3 py-1.5 font-medium">Phase</th>
|
||||
<th className="px-3 py-1.5 text-right font-medium">At</th>
|
||||
<th className="px-3 py-1.5 text-right font-medium">Dur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.phases.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-muted-foreground" colSpan={3}>
|
||||
No events recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
report.phases.map((p, i) => (
|
||||
<tr key={`${p.phase}-${p.attemptId ?? ''}-${i}`} className="border-t border-glass-border/50">
|
||||
<td className="px-3 py-1.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
outcomeDotClass(p.outcome, p.critical),
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono">{p.phase}</span>
|
||||
{p.proxied && <span className="text-[0.6rem] text-muted-foreground">proxy</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right font-mono tabular-nums text-muted-foreground">
|
||||
{formatOffset(p.offsetMs)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right font-mono tabular-nums text-muted-foreground">
|
||||
{p.durationMs == null ? '-' : formatMs(p.durationMs)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{report.anyProxied && (
|
||||
<p className="border-t border-glass-border px-3 py-2 text-[0.65rem] leading-snug text-muted-foreground">
|
||||
Some requests were proxied to a remote node. Nodes and proxy debug logs
|
||||
need developer mode enabled on the gateway to appear.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-1.5 border-t border-glass-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
data-testid="hydration-copy"
|
||||
className="flex items-center gap-1 rounded-md border border-glass-border px-2 py-1 text-xs hover:bg-accent"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearReport}
|
||||
data-testid="hydration-clear"
|
||||
className="flex items-center gap-1 rounded-md border border-glass-border px-2 py-1 text-xs hover:bg-accent"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-label="Collapse hydration timing"
|
||||
data-testid="hydration-collapse"
|
||||
className="flex items-center gap-1 rounded-md border border-glass-border px-2 py-1 text-xs hover:bg-accent"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { HydrationReport, HydrationSnapshot } from '@/lib/hydrationTiming';
|
||||
|
||||
let mockSnapshot: HydrationSnapshot;
|
||||
let mockListVisibleMs: number | null;
|
||||
let mockReport: HydrationReport;
|
||||
|
||||
vi.mock('@/hooks/useHydrationTiming', () => ({
|
||||
useHydrationTiming: () => ({ snapshot: mockSnapshot, listVisibleMs: mockListVisibleMs }),
|
||||
}));
|
||||
|
||||
const clearReportMock = vi.fn();
|
||||
vi.mock('@/lib/hydrationTiming', () => ({
|
||||
getHydrationReport: () => mockReport,
|
||||
clearReport: () => clearReportMock(),
|
||||
}));
|
||||
|
||||
const copyMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: (text: string) => copyMock(text) }));
|
||||
|
||||
import { HydrationTimingPanel } from '../HydrationTimingPanel';
|
||||
|
||||
function snapshot(events: HydrationSnapshot['events'] = []): HydrationSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
clock: 'performance.now',
|
||||
bootSessionId: 'boot-1',
|
||||
bootStartAt: 0,
|
||||
nodeSessionId: 'node-2',
|
||||
nodeId: 1,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function report(over: Partial<HydrationReport> = {}): HydrationReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
capturedAt: 0,
|
||||
clock: 'performance.now',
|
||||
bootSessionId: 'boot-1',
|
||||
nodeSessionId: 'node-2',
|
||||
nodeId: 1,
|
||||
listVisibleMs: 1200,
|
||||
anyProxied: false,
|
||||
phases: [
|
||||
{ phase: 'boot_start', kind: 'milestone', offsetMs: 0, critical: true, outcome: 'ok' },
|
||||
{ phase: 'list_visible', kind: 'milestone', offsetMs: 1200, uiCommitMs: 1200, critical: true, outcome: 'ok' },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearReportMock.mockClear();
|
||||
copyMock.mockClear();
|
||||
mockSnapshot = snapshot();
|
||||
mockListVisibleMs = 1200;
|
||||
mockReport = report();
|
||||
});
|
||||
|
||||
describe('HydrationTimingPanel', () => {
|
||||
it('shows the list_visible elapsed time on the collapsed chip', () => {
|
||||
render(<HydrationTimingPanel />);
|
||||
expect(screen.getByTestId('hydration-chip')).toHaveTextContent('list 1.2s');
|
||||
});
|
||||
|
||||
it('shows an ellipsis before list_visible commits', () => {
|
||||
mockListVisibleMs = null;
|
||||
render(<HydrationTimingPanel />);
|
||||
expect(screen.getByTestId('hydration-chip')).toHaveTextContent('list …');
|
||||
});
|
||||
|
||||
it('expands into the phase table and collapses again', () => {
|
||||
render(<HydrationTimingPanel />);
|
||||
fireEvent.click(screen.getByTestId('hydration-chip'));
|
||||
|
||||
expect(screen.getByTestId('hydration-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('list_visible')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('hydration-collapse'));
|
||||
expect(screen.queryByTestId('hydration-panel')).toBeNull();
|
||||
expect(screen.getByTestId('hydration-chip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses on Escape', () => {
|
||||
render(<HydrationTimingPanel />);
|
||||
fireEvent.click(screen.getByTestId('hydration-chip'));
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
expect(screen.queryByTestId('hydration-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('copies the hydration report as pretty JSON', async () => {
|
||||
render(<HydrationTimingPanel />);
|
||||
fireEvent.click(screen.getByTestId('hydration-chip'));
|
||||
fireEvent.click(screen.getByTestId('hydration-copy'));
|
||||
|
||||
await waitFor(() => expect(copyMock).toHaveBeenCalledTimes(1));
|
||||
expect(copyMock).toHaveBeenCalledWith(JSON.stringify(mockReport, null, 2));
|
||||
});
|
||||
|
||||
it('clears the report', () => {
|
||||
render(<HydrationTimingPanel />);
|
||||
fireEvent.click(screen.getByTestId('hydration-chip'));
|
||||
fireEvent.click(screen.getByTestId('hydration-clear'));
|
||||
expect(clearReportMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('notes gateway developer mode when any event was proxied', () => {
|
||||
mockReport = report({ anyProxied: true });
|
||||
render(<HydrationTimingPanel />);
|
||||
fireEvent.click(screen.getByTestId('hydration-chip'));
|
||||
expect(screen.getByText(/gateway/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user