mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user