mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +00:00
perf(frontend): coalesce concurrent stack statuses fetches (#1816)
* perf(frontend): coalesce concurrent stack statuses fetches Sidebar and dashboard both requested GET /stacks/statuses for the same node; share one in-flight promise keyed by explicit nodeId so boot and invalidate overlap stop doubling the hot path. * fix(frontend): clear statuses coalescer by entry id Avoid Promise identity checks that CodeQL flags as missing await. Numeric entry ids still protect a newer in-flight fetch after logout clear.
This commit is contained in:
@@ -30,6 +30,7 @@ vi.mock('@/lib/utils', async () => {
|
||||
});
|
||||
|
||||
import { useDashboardData } from '../useDashboardData';
|
||||
import { __resetStackStatusesFetchForTests } from '@/lib/stackStatusesFetch';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
@@ -91,6 +92,7 @@ async function tickSys(): Promise<void> {
|
||||
|
||||
beforeEach(() => {
|
||||
pollCallbacks = [];
|
||||
__resetStackStatusesFetchForTests();
|
||||
apiFetchMock.mockReset();
|
||||
useNodesMock.mockReset();
|
||||
useNodesMock.mockReturnValue({
|
||||
@@ -100,6 +102,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetStackStatusesFetchForTests();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('@/lib/utils', async () => {
|
||||
});
|
||||
|
||||
import { useDashboardData } from '../useDashboardData';
|
||||
import { __resetStackStatusesFetchForTests } from '@/lib/stackStatusesFetch';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
@@ -49,6 +50,7 @@ const SYS_PAYLOAD = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetStackStatusesFetchForTests();
|
||||
apiFetchMock.mockReset();
|
||||
apiFetchMock.mockImplementation((endpoint: string) => {
|
||||
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
|
||||
@@ -65,6 +67,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetStackStatusesFetchForTests();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -169,7 +172,7 @@ describe('useDashboardData stackStatuses load states', () => {
|
||||
expect(result.current.stackStatusesLoadStatus).toBe('success');
|
||||
});
|
||||
|
||||
it('ignores an older soft success after a newer foreground retry', async () => {
|
||||
it('joins a foreground retry onto an in-flight soft statuses fetch', async () => {
|
||||
const resolvers: Array<(r: Response) => void> = [];
|
||||
apiFetchMock.mockImplementation((endpoint: string) => {
|
||||
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
|
||||
@@ -200,27 +203,20 @@ describe('useDashboardData stackStatuses load states', () => {
|
||||
result.current.retryStackStatuses();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(resolvers).toHaveLength(3);
|
||||
// Soft and foreground share one in-flight /stacks/statuses.
|
||||
expect(resolvers).toHaveLength(2);
|
||||
|
||||
const softMap = { 'old.yml': { status: 'exited' as const } };
|
||||
const retryMap = { 'web.yml': { status: 'running' as const } };
|
||||
const sharedMap = { 'web.yml': { status: 'running' as const } };
|
||||
await act(async () => {
|
||||
resolvers[1](okJson(softMap));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.stackStatuses).toEqual({});
|
||||
|
||||
await act(async () => {
|
||||
resolvers[2](okJson(retryMap));
|
||||
resolvers[1](okJson(sharedMap));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.stackStatusesLoadStatus).toBe('success');
|
||||
expect(result.current.stackStatuses).toEqual(retryMap);
|
||||
expect(result.current.stackStatuses).toEqual(sharedMap);
|
||||
});
|
||||
|
||||
it('ignores an older soft failure after a newer foreground retry success', async () => {
|
||||
it('lets the newer foreground generation commit a shared in-flight failure', async () => {
|
||||
const resolvers: Array<(r: Response) => void> = [];
|
||||
apiFetchMock.mockImplementation((endpoint: string) => {
|
||||
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
|
||||
@@ -237,7 +233,7 @@ describe('useDashboardData stackStatuses load states', () => {
|
||||
expect(resolvers).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
resolvers[0](okJson({}));
|
||||
resolvers[0](okJson({ 'web.yml': { status: 'running' } }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -250,23 +246,16 @@ describe('useDashboardData stackStatuses load states', () => {
|
||||
result.current.retryStackStatuses();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(resolvers).toHaveLength(3);
|
||||
|
||||
const retryMap = { 'web.yml': { status: 'running' as const } };
|
||||
await act(async () => {
|
||||
resolvers[2](okJson(retryMap));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.stackStatuses).toEqual(retryMap);
|
||||
expect(resolvers).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
resolvers[1](new Response('nope', { status: 500 }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.stackStatusesLoadStatus).toBe('success');
|
||||
expect(result.current.stackStatuses).toEqual(retryMap);
|
||||
// Soft's older generation is ignored; the foreground generation commits
|
||||
// the shared failure as an error status (rows are not cleared on failure).
|
||||
expect(result.current.stackStatusesLoadStatus).toBe('error');
|
||||
});
|
||||
|
||||
it('lets a slow foreground statuses response commit after soft poll and invalidate ticks', async () => {
|
||||
@@ -382,4 +371,30 @@ describe('useDashboardData stackStatuses load states', () => {
|
||||
expect(result.current.stackStatusesLoadStatus).toBe('error');
|
||||
expect(result.current.stackStatuses).toEqual({});
|
||||
});
|
||||
|
||||
it('does not fetch statuses while activeNode is unresolved and stays loading', async () => {
|
||||
useNodesMock.mockReturnValue({
|
||||
activeNode: undefined,
|
||||
nodes: [],
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useDashboardData());
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
expect(countFetchCalls('/stacks/statuses')).toBe(0);
|
||||
expect(result.current.stackStatusesLoadStatus).toBe('loading');
|
||||
expect(result.current.stackStatuses).toEqual({});
|
||||
|
||||
useNodesMock.mockReturnValue({
|
||||
activeNode: { id: 7, name: 'Remote', type: 'remote' },
|
||||
nodes: [{ id: 7, name: 'Remote', type: 'remote' }],
|
||||
});
|
||||
rerender();
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
expect(countFetchCalls('/stacks/statuses')).toBe(1);
|
||||
expect(apiFetchMock.mock.calls.some(
|
||||
(call) => call[0] === '/stacks/statuses' && call[1]?.nodeId === 7,
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { fetchStackStatusesShared } from '@/lib/stackStatusesFetch';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
import type {
|
||||
Stats,
|
||||
@@ -270,6 +271,10 @@ export function useDashboardData(): DashboardData {
|
||||
currentNodeId: number | undefined,
|
||||
mode: 'foreground' | 'soft',
|
||||
) => {
|
||||
// Skip until activeNode resolves. Passing null/"local" here would fetch the
|
||||
// hub while a remembered remote node is still loading and commit under an
|
||||
// unresolved guard.
|
||||
if (currentNodeId === undefined) return;
|
||||
if (nodeIdRef.current !== currentNodeId) return;
|
||||
if (mode === 'soft' && stackStatusesInFlightRef.current) return;
|
||||
const generation = ++stackStatusesFetchGenRef.current;
|
||||
@@ -279,19 +284,18 @@ export function useDashboardData(): DashboardData {
|
||||
setStackStatusesLoadError(null);
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/stacks/statuses');
|
||||
const result = await fetchStackStatusesShared(currentNodeId);
|
||||
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
|
||||
if (!res.ok) {
|
||||
if (!result.ok) {
|
||||
commitStackStatusesFailure(
|
||||
currentNodeId,
|
||||
generation,
|
||||
mode,
|
||||
`Could not load stack health (${res.status}).`,
|
||||
`Could not load stack health (${result.status}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const body: unknown = await res.json();
|
||||
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
|
||||
const body = result.body;
|
||||
if (body && typeof body === 'object' && !Array.isArray(body)) {
|
||||
// Drop any entry isValidStatusEntry rejects rather than trusting the
|
||||
// whole map: one bad entry must not crash or misrepresent the rest of
|
||||
@@ -352,6 +356,9 @@ export function useDashboardData(): DashboardData {
|
||||
setStackStatusesLoadStatus('loading');
|
||||
setStackStatusesLoadError(null);
|
||||
const currentNodeId = nodeId;
|
||||
// Stay in loading while NodeContext has not resolved activeNode; do not
|
||||
// fetch against an unknown target (and do not treat that window as empty).
|
||||
if (currentNodeId === undefined) return;
|
||||
void fetchStackStatuses(currentNodeId, 'foreground');
|
||||
const cleanup = visibilityInterval(() => {
|
||||
void fetchStackStatuses(currentNodeId, 'soft');
|
||||
@@ -370,6 +377,7 @@ export function useDashboardData(): DashboardData {
|
||||
// refresh instead of one HTTP request per event.
|
||||
useEffect(() => {
|
||||
const currentNodeId = nodeId;
|
||||
if (currentNodeId === undefined) return;
|
||||
let active = true;
|
||||
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const refresh = async () => {
|
||||
|
||||
Reference in New Issue
Block a user