Cleanup and fix transient error detection in review
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { Header, useViewportMode } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
@@ -49,7 +49,7 @@ import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
import { fetchAiSession, fetchUnreadCount } from "./api";
|
||||
import { fetchAiSession, fetchUnreadCount, reportDashboardPerf } from "./api";
|
||||
|
||||
function AppInner() {
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
@@ -133,6 +133,9 @@ function AppInner() {
|
||||
);
|
||||
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
const mountTimeRef = useRef(performance.now());
|
||||
const projectsReadyLoggedRef = useRef(false);
|
||||
const projectReadyLoggedRef = useRef(false);
|
||||
|
||||
const loadingStage = useMemo<DashboardLoaderStage>(() => {
|
||||
if (projectsLoading) return "projects";
|
||||
@@ -140,6 +143,21 @@ function AppInner() {
|
||||
return "tasks";
|
||||
}, [projectsLoading, currentProjectLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectsLoading && !projectsReadyLoggedRef.current) {
|
||||
projectsReadyLoggedRef.current = true;
|
||||
const msg = `projects loaded at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
|
||||
console.log(`[App] ${msg}`);
|
||||
reportDashboardPerf("[App]", msg);
|
||||
}
|
||||
if (!currentProjectLoading && !projectReadyLoggedRef.current) {
|
||||
projectReadyLoggedRef.current = true;
|
||||
const msg = `current-project resolved at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
|
||||
console.log(`[App] ${msg}`);
|
||||
reportDashboardPerf("[App]", msg);
|
||||
}
|
||||
}, [projectsLoading, currentProjectLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoadComplete) {
|
||||
return;
|
||||
@@ -149,7 +167,11 @@ function AppInner() {
|
||||
return;
|
||||
}
|
||||
|
||||
const settleStart = performance.now();
|
||||
const settleTimer = window.setTimeout(() => {
|
||||
const msg = `dashboard ready at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount (settle delay=${Math.round(performance.now() - settleStart)}ms)`;
|
||||
console.log(`[App] ${msg}`);
|
||||
reportDashboardPerf("[App]", msg);
|
||||
setInitialLoadComplete(true);
|
||||
}, 200);
|
||||
|
||||
|
||||
@@ -3454,6 +3454,19 @@ export function fetchProjectsAcrossNodes(): Promise<ProjectInfoWithSource[]> {
|
||||
return api<ProjectInfoWithSource[]>("/projects/across-nodes");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a client-side perf measurement to the shared dashboard-perf log on disk.
|
||||
* Used when browser devtools aren't available (e.g. mobile). Best-effort.
|
||||
*/
|
||||
export function reportDashboardPerf(source: string, message: string): void {
|
||||
void api("/_perf/dashboard-load", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source, message }),
|
||||
}).catch(() => {
|
||||
// best-effort only
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch all registered nodes */
|
||||
export function fetchNodes(): Promise<NodeInfo[]> {
|
||||
return api<NodeInfo[]>("/nodes");
|
||||
|
||||
@@ -9,12 +9,14 @@ vi.mock("../../api", () => ({
|
||||
registerProject: vi.fn(),
|
||||
unregisterProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
reportDashboardPerf: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
|
||||
const mockUpdateProject = vi.mocked(api.updateProject);
|
||||
const mockRegisterProject = vi.mocked(api.registerProject);
|
||||
const mockUnregisterProject = vi.mocked(api.unregisterProject);
|
||||
const mockReportDashboardPerf = vi.mocked(api.reportDashboardPerf);
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
@@ -28,6 +30,7 @@ describe("useProjects", () => {
|
||||
mockUpdateProject.mockReset();
|
||||
mockRegisterProject.mockReset();
|
||||
mockUnregisterProject.mockReset();
|
||||
mockReportDashboardPerf.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ProjectInfo } from "../api";
|
||||
import {
|
||||
fetchProjectsAcrossNodes,
|
||||
registerProject,
|
||||
reportDashboardPerf,
|
||||
unregisterProject,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
@@ -59,13 +60,22 @@ export function useProjects(): UseProjectsResult {
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const data = await fetchProjectsAcrossNodes();
|
||||
const elapsed = Math.round(performance.now() - t0);
|
||||
const msg = `initial fetchProjectsAcrossNodes took ${elapsed}ms (${data.length} projects)`;
|
||||
console.log(`[useProjects] ${msg}`);
|
||||
reportDashboardPerf("[useProjects]", msg);
|
||||
if (!cancelled) {
|
||||
setProjects(data);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
const elapsed = Math.round(performance.now() - t0);
|
||||
const msg = `initial fetch failed after ${elapsed}ms: ${err instanceof Error ? err.message : String(err)}`;
|
||||
console.warn(`[useProjects] ${msg}`);
|
||||
reportDashboardPerf("[useProjects]", msg);
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch projects");
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ declare module "express" {
|
||||
}
|
||||
import multer from "multer";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { mkdtemp, access, stat, mkdir, readdir, rm, readFile as fsReadFile } from "node:fs/promises";
|
||||
import { mkdtemp, access, stat, mkdir, readdir, rm, readFile as fsReadFile, appendFile } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline as streamPipeline } from "node:stream/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { resolve, sep, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { tmpdir, homedir } from "node:os";
|
||||
import * as nodeFs from "node:fs";
|
||||
|
||||
import { promisify } from "node:util";
|
||||
@@ -1880,6 +1880,16 @@ function checkSessionLock(
|
||||
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||
const router = Router();
|
||||
|
||||
// Dashboard load perf log — server and client timings get appended here so
|
||||
// they can be inspected without browser devtools (e.g. on mobile).
|
||||
const PERF_LOG_PATH = join(homedir(), ".fusion", "dashboard-perf.log");
|
||||
const perfLog = (source: string, message: string): void => {
|
||||
const line = `[${new Date().toISOString()}] ${source} ${message}\n`;
|
||||
appendFile(PERF_LOG_PATH, line).catch(() => {
|
||||
// best-effort only
|
||||
});
|
||||
};
|
||||
|
||||
function prioritizeProjectsForCurrentDirectory<T extends { path: string }>(projects: T[]): T[] {
|
||||
const cwd = resolve(process.cwd());
|
||||
|
||||
@@ -14692,26 +14702,61 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* Returns: Array of projects with nodeId and _sourceNodeName for remote projects.
|
||||
*/
|
||||
router.get("/projects/across-nodes", async (_req, res) => {
|
||||
const t0 = performance.now();
|
||||
const timings: Record<string, number> = {};
|
||||
const mark = (label: string, from: number) => {
|
||||
timings[label] = Math.round(performance.now() - from);
|
||||
};
|
||||
try {
|
||||
const tImport = performance.now();
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
const central = new CentralCore();
|
||||
mark("import+construct", tImport);
|
||||
|
||||
const tInit = performance.now();
|
||||
await central.init();
|
||||
mark("central.init", tInit);
|
||||
|
||||
// Reconcile stale "initializing" projects before listing
|
||||
const tReconcile = performance.now();
|
||||
await central.reconcileProjectStatuses();
|
||||
mark("reconcileProjectStatuses", tReconcile);
|
||||
|
||||
// Get local projects
|
||||
const localProjects = await central.listProjects();
|
||||
|
||||
// Get all registered nodes
|
||||
const allNodes = await central.listNodes();
|
||||
// Get local projects and registered nodes in parallel
|
||||
const tListLocal = performance.now();
|
||||
const [localProjects, allNodes] = await Promise.all([
|
||||
central.listProjects(),
|
||||
central.listNodes(),
|
||||
]);
|
||||
mark("listProjects+listNodes", tListLocal);
|
||||
|
||||
// Filter to online remote nodes with URLs
|
||||
const remoteNodes = allNodes.filter(
|
||||
(node) => node.type === "remote" && node.status === "online" && node.url
|
||||
);
|
||||
|
||||
// Short-circuit: zero remote nodes means we behave exactly like /projects.
|
||||
// Skip the Promise.allSettled machinery entirely so local-only setups pay
|
||||
// no cross-node aggregation overhead.
|
||||
if (remoteNodes.length === 0) {
|
||||
const tPrioritize = performance.now();
|
||||
const prioritizedProjects = prioritizeProjectsForCurrentDirectory(localProjects);
|
||||
mark("prioritize", tPrioritize);
|
||||
|
||||
const tClose = performance.now();
|
||||
await central.close();
|
||||
mark("central.close", tClose);
|
||||
|
||||
timings.total = Math.round(performance.now() - t0);
|
||||
const msg = `local-only path (${localProjects.length} projects) timings=${JSON.stringify(timings)}`;
|
||||
console.log(`[projects:across-nodes] ${msg}`);
|
||||
perfLog("[projects:across-nodes]", msg);
|
||||
res.json(prioritizedProjects);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch projects from all remote nodes in parallel
|
||||
const tRemote = performance.now();
|
||||
const remoteProjectArrays = await Promise.allSettled(
|
||||
remoteNodes.map(async (node) => {
|
||||
const controller = new AbortController();
|
||||
@@ -14752,6 +14797,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
})
|
||||
);
|
||||
mark("remoteFetch", tRemote);
|
||||
|
||||
// Collect successful remote projects, log failures
|
||||
type RemoteProject = {
|
||||
@@ -14782,10 +14828,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const mergedProjects = [...localProjects, ...remoteProjects];
|
||||
|
||||
// Apply directory prioritization
|
||||
const tPrioritize = performance.now();
|
||||
const prioritizedProjects = prioritizeProjectsForCurrentDirectory(mergedProjects);
|
||||
mark("prioritize", tPrioritize);
|
||||
|
||||
const tClose = performance.now();
|
||||
await central.close();
|
||||
mark("central.close", tClose);
|
||||
|
||||
timings.total = Math.round(performance.now() - t0);
|
||||
const msg = `${remoteNodes.length} remote node(s), ${localProjects.length} local + ${remoteProjects.length} remote projects timings=${JSON.stringify(timings)}`;
|
||||
console.log(`[projects:across-nodes] ${msg}`);
|
||||
perfLog("[projects:across-nodes]", msg);
|
||||
res.json(prioritizedProjects);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -14795,6 +14849,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/_perf/dashboard-load
|
||||
* Collect client-side dashboard-load timings. Appended to the same perf log
|
||||
* the server uses so both sides can be inspected together without devtools.
|
||||
* Body: { source: string, message: string } — message typically JSON-stringified timings.
|
||||
*/
|
||||
router.post("/_perf/dashboard-load", (req, res) => {
|
||||
try {
|
||||
const source = typeof req.body?.source === "string" ? req.body.source.slice(0, 64) : "[client]";
|
||||
const message = typeof req.body?.message === "string" ? req.body.message.slice(0, 1024) : "";
|
||||
perfLog(source, message);
|
||||
res.json({ ok: true });
|
||||
} catch {
|
||||
res.status(200).json({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/projects
|
||||
* Register a new project.
|
||||
|
||||
Reference in New Issue
Block a user