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:
Anso
2026-07-14 17:24:25 -04:00
committed by GitHub
parent 4079cb9198
commit b70a529656
26 changed files with 2449 additions and 32 deletions
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
import { apiFetch } from '@/lib/api';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import { useDeveloperMode } from '../useDeveloperMode';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function settingsResponse(developerMode: string) {
return { ok: true, status: 200, json: async () => ({ developer_mode: developerMode }) };
}
beforeEach(() => {
mockedFetch.mockReset();
// Failures are logged, not thrown; silence the expected console noise.
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('useDeveloperMode', () => {
it('enables when the active node has developer_mode on', async () => {
mockedFetch.mockResolvedValue(settingsResponse('1'));
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(result.current).toBe(true));
});
it('discards a delayed node A response after switching to node B', async () => {
mockedFetch.mockImplementation((_url: string, opts?: { nodeId?: number | null }) => {
if (opts?.nodeId === 1) {
// Node A: developer mode on, but its response is slow.
return new Promise((resolve) => setTimeout(() => resolve(settingsResponse('1')), 50));
}
// Node B: developer mode off, fast.
return Promise.resolve(settingsResponse('0'));
});
const { result, rerender } = renderHook(({ id }) => useDeveloperMode(id), {
initialProps: { id: 1 as number | undefined },
});
rerender({ id: 2 });
await waitFor(() => expect(result.current).toBe(false));
// Node A's late response must not flip node B to enabled.
await new Promise((r) => setTimeout(r, 80));
expect(result.current).toBe(false);
});
it('returns false when the settings fetch rejects', async () => {
mockedFetch.mockRejectedValue(new Error('network down'));
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
expect(result.current).toBe(false);
});
it('returns false on a non-ok settings response', async () => {
mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
expect(result.current).toBe(false);
});
it('refetches when a developer_mode settings change is broadcast', async () => {
let dev = '0';
mockedFetch.mockImplementation(() => Promise.resolve(settingsResponse(dev)));
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(result.current).toBe(false));
dev = '1';
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_SETTINGS_CHANGED, { detail: { changedKeys: ['developer_mode'] } }),
);
});
await waitFor(() => expect(result.current).toBe(true));
});
it('ignores a settings change that does not touch developer_mode', async () => {
mockedFetch.mockResolvedValue(settingsResponse('0'));
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(result.current).toBe(false));
const callsBefore = mockedFetch.mock.calls.length;
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_SETTINGS_CHANGED, { detail: { changedKeys: ['log_retention_days'] } }),
);
});
expect(mockedFetch.mock.calls.length).toBe(callsBefore);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch } from '@/lib/api';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
/**
* Reads the active node's `developer_mode` setting, race-safe against node
* switches (mirrors the ownership pattern in `useImageUpdates`).
*
* The setting is node-scoped: `/settings` is proxied to whichever node is
* active, so a mid-flight switch must never let node A's response flip the
* result for node B. A generation counter discards stale responses, and an
* owner check returns `false` on the render before the reset effect fires.
*
* Any failure (network, non-ok, parse) resolves to `false` so the developer
* overlay stays hidden rather than flickering on a transient error.
*/
export function useDeveloperMode(activeNodeId: number | undefined): boolean {
const [enabled, setEnabled] = useState(false);
// Which node owns the current `enabled` value. When `activeNodeId` changes,
// React renders once with the old owner before the reset effect clears it;
// returning false on a mismatch avoids a one-frame flash of the wrong node's
// developer state.
const [ownerNodeId, setOwnerNodeId] = useState<number | undefined>(activeNodeId);
// Every node change increments this, and every await is gated against it so a
// slow response from a previous node is dropped.
const genRef = useRef(0);
const refresh = useCallback(async () => {
const gen = ++genRef.current;
const targetNodeId = activeNodeId ?? null;
try {
const res = await apiFetch('/settings', { nodeId: targetNodeId });
if (genRef.current !== gen) return;
if (!res.ok) {
console.error('[DeveloperMode] settings fetch returned', res.status);
setEnabled(false);
return;
}
const data = (await res.json()) as Record<string, string>;
if (genRef.current !== gen) return;
setEnabled(data.developer_mode === '1');
} catch (e) {
if (genRef.current !== gen) return;
console.error('[DeveloperMode] settings fetch failed:', e);
setEnabled(false);
}
}, [activeNodeId]);
// Pin the settings-event handler to the latest closure without retriggering
// the listener effect on every render.
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
// Reset and refetch on mount and on node change. Capture the owning node and
// clear the flag BEFORE fetching so the guard returns false until the new
// node's response arrives.
useEffect(() => {
genRef.current += 1;
setEnabled(false); // eslint-disable-line react-hooks/set-state-in-effect
setOwnerNodeId(activeNodeId); // eslint-disable-line react-hooks/set-state-in-effect
void refreshRef.current();
}, [activeNodeId]);
// Propagate a developer-mode toggle immediately. Refetch when the change set
// names developer_mode, or when the detail is missing (unknown change set).
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<Partial<SenchoSettingsChangedDetail>>).detail;
if (!detail?.changedKeys || detail.changedKeys.includes('developer_mode')) {
void refreshRef.current();
}
};
window.addEventListener(SENCHO_SETTINGS_CHANGED, handler);
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler);
}, []);
const isOwner = activeNodeId !== undefined && activeNodeId === ownerNodeId;
return isOwner ? enabled : false;
}
+21
View File
@@ -0,0 +1,21 @@
import { useSyncExternalStore } from 'react';
import { subscribe, getSnapshot, listVisibleMsFrom } from '@/lib/hydrationTiming';
import type { HydrationSnapshot } from '@/lib/hydrationTiming';
export interface UseHydrationTiming {
snapshot: HydrationSnapshot;
/** Elapsed ms from boot to `list_visible`, or null before it commits. */
listVisibleMs: number | null;
}
/** Subscribe to the hydration timing store and expose the current snapshot
* plus the derived `list_visible` elapsed time for the collapsed chip.
* Derives from the snapshot React last read so the chip stays consistent
* with the events on screen, not a later live store mutation. */
export function useHydrationTiming(): UseHydrationTiming {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return {
snapshot,
listVisibleMs: listVisibleMsFrom(snapshot.events, snapshot.bootStartAt),
};
}
+13
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch } from '@/lib/api';
import { markMilestone } from '@/lib/hydrationTiming';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates';
@@ -31,6 +32,10 @@ export function useImageUpdates(activeNodeId: number | undefined) {
// discarded.
const genRef = useRef(0);
// Node the image_updates_ready milestone last fired for, so it records once
// per node session (re-firing after a node switch) rather than every poll.
const imageUpdatesReadyNodeRef = useRef<number | null | undefined>(undefined);
const refresh = useCallback(async () => {
const gen = ++genRef.current;
const targetNodeId = activeNodeId ?? null;
@@ -92,6 +97,14 @@ export function useImageUpdates(activeNodeId: number | undefined) {
};
await Promise.allSettled([fetchStatus(), fetchDetail()]);
// Background milestone: both image-update requests have settled for the
// active node. Fire once per node session, and only if this refresh still
// owns the generation (a node switch mid-flight defers to the new node).
if (genRef.current === gen && imageUpdatesReadyNodeRef.current !== targetNodeId) {
imageUpdatesReadyNodeRef.current = targetNodeId;
markMilestone('image_updates_ready');
}
}, [activeNodeId]);
// Pin the interval to the latest closure without retriggering it on