Merge commit 'c04442f6ea8ead6b65330bc03dc434dc9278985c'
This commit is contained in:
@@ -90,3 +90,5 @@ Dashboard Phase 1 resume instrumentation adds observation-only client/server tra
|
|||||||
- Server ring (5000, in-memory): `GET /api/diagnostics/resume-events?limit=&since=&view=` returns `{ events, droppedSinceLastRead }`
|
- Server ring (5000, in-memory): `GET /api/diagnostics/resume-events?limit=&since=&view=` returns `{ events, droppedSinceLastRead }`
|
||||||
- Client batching: POST `/api/diagnostics/resume-events` in idle batches (`<=25` per POST).
|
- Client batching: POST `/api/diagnostics/resume-events` in idle batches (`<=25` per POST).
|
||||||
- Disable knob: `window.__fusionDebug.resumeInstrumentation.setEnabled(false)`.
|
- Disable knob: `window.__fusionDebug.resumeInstrumentation.setEnabled(false)`.
|
||||||
|
|
||||||
|
FN-5415 extends this coverage across remaining board/data visibility hooks: `useNodes`, `useMeshState`, `useProjects`, and `useManagedDockerNodes`. Each now emits `trigger: "visibility"` with `reason: "debounced-refresh"` when refresh is taken and `reason: "debounce-skipped"` (including `detail.timeSinceLastRefreshMs`) when suppressed by debounce. This completes board/data-hook resume-correlation coverage needed for FN-5392 Phase 2 remediation analysis.
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as api from "../../api";
|
||||||
|
|
||||||
|
const { recordResumeEvent } = vi.hoisted(() => ({
|
||||||
|
recordResumeEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchManagedDockerNodes: vi.fn(),
|
||||||
|
fetchManagedDockerNodeContainerStatus: vi.fn(),
|
||||||
|
fetchDockerNodeLogs: vi.fn(),
|
||||||
|
createManagedDockerNode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/resumeInstrumentation", () => ({
|
||||||
|
recordResumeEvent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchManagedDockerNodes = vi.mocked(api.fetchManagedDockerNodes);
|
||||||
|
|
||||||
|
async function flushPromises(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useManagedDockerNodes resume instrumentation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
mockFetchManagedDockerNodes.mockReset();
|
||||||
|
recordResumeEvent.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits debounced-refresh then debounce-skipped and refreshes once for visibility events", async () => {
|
||||||
|
mockFetchManagedDockerNodes.mockResolvedValue([]);
|
||||||
|
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||||
|
|
||||||
|
const { useManagedDockerNodes } = await import("../useManagedDockerNodes");
|
||||||
|
renderHook(() => useManagedDockerNodes());
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset to isolate visibility-driven refreshes from initial mount fetch.
|
||||||
|
mockFetchManagedDockerNodes.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1100);
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordResumeEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
view: "useManagedDockerNodes",
|
||||||
|
trigger: "visibility",
|
||||||
|
reason: "debounced-refresh",
|
||||||
|
}));
|
||||||
|
expect(recordResumeEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
view: "useManagedDockerNodes",
|
||||||
|
trigger: "visibility",
|
||||||
|
reason: "debounce-skipped",
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
timeSinceLastRefreshMs: expect.any(Number),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(recordResumeEvent).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetchManagedDockerNodes).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as api from "../../api";
|
||||||
|
|
||||||
|
const { recordResumeEvent } = vi.hoisted(() => ({
|
||||||
|
recordResumeEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchMeshState: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/resumeInstrumentation", () => ({
|
||||||
|
recordResumeEvent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchMeshState = vi.mocked(api.fetchMeshState);
|
||||||
|
|
||||||
|
async function flushPromises(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function meshPayload(nodeId: string) {
|
||||||
|
return {
|
||||||
|
collectedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
sourceNodeId: "local",
|
||||||
|
nodes: [{ nodeId, nodeName: nodeId, nodeUrl: undefined, nodeType: "local", status: "online", metrics: null, lastSeen: "2026-01-01T00:00:00.000Z", connectedAt: "2026-01-01T00:00:00.000Z", knownPeers: [] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useMeshState resume instrumentation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
mockFetchMeshState.mockReset();
|
||||||
|
recordResumeEvent.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits debounced-refresh then debounce-skipped and refreshes once for visibility events", async () => {
|
||||||
|
mockFetchMeshState.mockResolvedValue(meshPayload("local"));
|
||||||
|
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||||
|
|
||||||
|
const { useMeshState } = await import("../useMeshState");
|
||||||
|
renderHook(() => useMeshState());
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset to isolate visibility-driven refreshes from initial mount fetch.
|
||||||
|
mockFetchMeshState.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1100);
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordResumeEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
view: "useMeshState",
|
||||||
|
trigger: "visibility",
|
||||||
|
reason: "debounced-refresh",
|
||||||
|
}));
|
||||||
|
expect(recordResumeEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
view: "useMeshState",
|
||||||
|
trigger: "visibility",
|
||||||
|
reason: "debounce-skipped",
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
timeSinceLastRefreshMs: expect.any(Number),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(recordResumeEvent).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetchMeshState).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as api from "../../api";
|
||||||
|
|
||||||
|
const { recordResumeEvent } = vi.hoisted(() => ({
|
||||||
|
recordResumeEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchNodes: vi.fn(),
|
||||||
|
registerNode: vi.fn(),
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
unregisterNode: vi.fn(),
|
||||||
|
checkNodeHealth: vi.fn(),
|
||||||
|
discoverRemoteNodeProjects: vi.fn(),
|
||||||
|
fetchDockerNodeConfig: vi.fn(),
|
||||||
|
updateDockerNodeConfig: vi.fn(),
|
||||||
|
fetchDockerConfigDiff: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api-node", () => ({
|
||||||
|
persistNodeProjectPathMappings: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/resumeInstrumentation", () => ({
|
||||||
|
recordResumeEvent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchNodes = vi.mocked(api.fetchNodes);
|
||||||
|
|
||||||
|
async function flushPromises(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useNodes resume instrumentation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
mockFetchNodes.mockReset();
|
||||||
|
recordResumeEvent.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits debounced-refresh then debounce-skipped and refreshes once for visibility events", async () => {
|
||||||
|
mockFetchNodes.mockResolvedValue([]);
|
||||||
|
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||||
|
|
||||||
|
const { useNodes } = await import("../useNodes");
|
||||||
|
renderHook(() => useNodes());
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset to isolate visibility-driven refreshes from initial mount fetch.
|
||||||
|
mockFetchNodes.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1100);
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordResumeEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
view: "useNodes",
|
||||||
|
trigger: "visibility",
|
||||||
|
reason: "debounced-refresh",
|
||||||
|
}));
|
||||||
|
expect(recordResumeEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
view: "useNodes",
|
||||||
|
trigger: "visibility",
|
||||||
|
reason: "debounce-skipped",
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
timeSinceLastRefreshMs: expect.any(Number),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(recordResumeEvent).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetchNodes).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as api from "../../api";
|
||||||
|
import * as swrCache from "../../utils/swrCache";
|
||||||
|
|
||||||
|
const { recordResumeEvent } = vi.hoisted(() => ({
|
||||||
|
recordResumeEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchProjectsAcrossNodes: vi.fn(),
|
||||||
|
registerProject: vi.fn(),
|
||||||
|
updateProject: vi.fn(),
|
||||||
|
unregisterProject: vi.fn(),
|
||||||
|
hasNodeMappingsSupport: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/resumeInstrumentation", () => ({
|
||||||
|
recordResumeEvent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
|
||||||
|
const mockHasNodeMappingsSupport = vi.mocked(api.hasNodeMappingsSupport);
|
||||||
|
const mockReadCache = vi.spyOn(swrCache, "readCache");
|
||||||
|
|
||||||
|
async function flushPromises(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useProjects resume instrumentation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
mockFetchProjectsAcrossNodes.mockReset();
|
||||||
|
mockHasNodeMappingsSupport.mockReset();
|
||||||
|
mockReadCache.mockReset();
|
||||||
|
recordResumeEvent.mockReset();
|
||||||
|
mockReadCache.mockReturnValue(null);
|
||||||
|
mockHasNodeMappingsSupport.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits debounced-refresh then debounce-skipped and refreshes once for visibility events", async () => {
|
||||||
|
mockFetchProjectsAcrossNodes.mockResolvedValue([]);
|
||||||
|
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||||
|
|
||||||
|
const { useProjects } = await import("../useProjects");
|
||||||
|
renderHook(() => useProjects());
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset to isolate visibility-driven refreshes from initial mount fetch.
|
||||||
|
mockFetchProjectsAcrossNodes.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1100);
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordResumeEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||||
|
view: "useProjects",
|
||||||
|
trigger: "visibility",
|
||||||
|
reason: "debounced-refresh",
|
||||||
|
}));
|
||||||
|
expect(recordResumeEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||||
|
view: "useProjects",
|
||||||
|
trigger: "visibility",
|
||||||
|
reason: "debounce-skipped",
|
||||||
|
detail: expect.objectContaining({
|
||||||
|
timeSinceLastRefreshMs: expect.any(Number),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(recordResumeEvent).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFetchProjectsAcrossNodes).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
fetchManagedDockerNodeContainerStatus,
|
fetchManagedDockerNodeContainerStatus,
|
||||||
fetchManagedDockerNodes,
|
fetchManagedDockerNodes,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||||
|
|
||||||
export interface UseManagedDockerNodesResult {
|
export interface UseManagedDockerNodesResult {
|
||||||
dockerNodes: ManagedDockerNodeInfo[];
|
dockerNodes: ManagedDockerNodeInfo[];
|
||||||
@@ -68,11 +69,27 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastVisibilityRefreshRef.current < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
||||||
|
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useManagedDockerNodes",
|
||||||
|
trigger: "visibility",
|
||||||
|
projectId: undefined,
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "debounce-skipped",
|
||||||
|
detail: { timeSinceLastRefreshMs: timeSinceLastRefresh },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastVisibilityRefreshRef.current = now;
|
lastVisibilityRefreshRef.current = now;
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useManagedDockerNodes",
|
||||||
|
trigger: "visibility",
|
||||||
|
projectId: undefined,
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "debounced-refresh",
|
||||||
|
});
|
||||||
void refresh();
|
void refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { NodeMeshState } from "@fusion/core";
|
import type { NodeMeshState } from "@fusion/core";
|
||||||
import { fetchMeshState } from "../api";
|
import { fetchMeshState } from "../api";
|
||||||
|
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 10000;
|
const POLL_INTERVAL_MS = 10000;
|
||||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||||
@@ -56,8 +57,26 @@ export function useMeshState(): UseMeshStateResult {
|
|||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.visibilityState !== "visible") return;
|
if (document.visibilityState !== "visible") return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastVisibilityRefreshRef.current < VISIBILITY_REFRESH_DEBOUNCE_MS) return;
|
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
||||||
|
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useMeshState",
|
||||||
|
trigger: "visibility",
|
||||||
|
projectId: undefined,
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "debounce-skipped",
|
||||||
|
detail: { timeSinceLastRefreshMs: timeSinceLastRefresh },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
lastVisibilityRefreshRef.current = now;
|
lastVisibilityRefreshRef.current = now;
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useMeshState",
|
||||||
|
trigger: "visibility",
|
||||||
|
projectId: undefined,
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "debounced-refresh",
|
||||||
|
});
|
||||||
void refresh();
|
void refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
discoverRemoteNodeProjects,
|
discoverRemoteNodeProjects,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { persistNodeProjectPathMappings } from "../api-node";
|
import { persistNodeProjectPathMappings } from "../api-node";
|
||||||
|
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||||
|
|
||||||
export interface UseNodesResult {
|
export interface UseNodesResult {
|
||||||
nodes: NodeInfo[];
|
nodes: NodeInfo[];
|
||||||
@@ -86,10 +87,25 @@ export function useNodes(): UseNodesResult {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
||||||
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useNodes",
|
||||||
|
trigger: "visibility",
|
||||||
|
projectId: undefined,
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "debounce-skipped",
|
||||||
|
detail: { timeSinceLastRefreshMs: timeSinceLastRefresh },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastVisibilityRefreshRef.current = now;
|
lastVisibilityRefreshRef.current = now;
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useNodes",
|
||||||
|
trigger: "visibility",
|
||||||
|
projectId: undefined,
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "debounced-refresh",
|
||||||
|
});
|
||||||
void refresh();
|
void refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type ProjectNodeAvailability,
|
type ProjectNodeAvailability,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, clearCache, readCache, writeCache } from "../utils/swrCache";
|
import { SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, clearCache, readCache, writeCache } from "../utils/swrCache";
|
||||||
|
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||||
|
|
||||||
export interface UseProjectsResult {
|
export interface UseProjectsResult {
|
||||||
/** List of all registered projects (local + remote) */
|
/** List of all registered projects (local + remote) */
|
||||||
@@ -147,10 +148,25 @@ export function useProjects(): UseProjectsResult {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
||||||
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useProjects",
|
||||||
|
trigger: "visibility",
|
||||||
|
projectId: undefined,
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "debounce-skipped",
|
||||||
|
detail: { timeSinceLastRefreshMs: timeSinceLastRefresh },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastVisibilityRefreshRef.current = now;
|
lastVisibilityRefreshRef.current = now;
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useProjects",
|
||||||
|
trigger: "visibility",
|
||||||
|
projectId: undefined,
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "debounced-refresh",
|
||||||
|
});
|
||||||
void refresh();
|
void refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const qualityAppTests = [
|
|||||||
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
||||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||||
"app/context/**/*.test.tsx",
|
"app/context/**/*.test.tsx",
|
||||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
||||||
"app/utils/**/*.test.{ts,tsx}",
|
"app/utils/**/*.test.{ts,tsx}",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user