ca821008e2
Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max 2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli). Server - /api/analytics: the events-table scans run in the read worker (scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args - /api/workflows/skill-usage: +event_type and a tool_name range instead of LIKE, so it uses idx_events_tool (784 ms → a few ms) - idx_events_session_type_tool (session_id, event_type, tool_name, created_at) replaces idx_events_session_type: covering reads for session stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB cache_size and 256 MB mmap_size on both connections - WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable connection; a failed checkpoint restores the automatic one (hook COMMIT p95 109 ms → 15 ms at 100 hooks/s) - tmux: one list-sessions answers a whole GET /api/lanes and the heal beat (liveSessions), shared for 1 s and cleared by this process's new/kill — GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat skips tmux when nothing runs; attach options chained into 2 spawns - hooks: TurnDuration/APIError deduped against one read per hook instead of a query per transcript entry (23 ms/hook on a 763-turn session); one lane scan per hook; hourly upserts skip rows that did not grow; frames are sent after the transaction commits (and after the pane binding); new_event carries its row id; a split pane's (slot 1–9) hooks no longer rebind the lane's session_id - watchdog liveness probe runs ps asynchronously; transcript newline scan via indexOf; startup subagent backfill fingerprints the files it already checked - pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB) - static: hashed assets served from their pre-compressed .br/.gz sibling Client - hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes, lane cards, git header, changes panel, settings); no per-tab hourly /updates/check (the server already checks) - refetch storms throttled (workspace tree/session list 3 s, /api/run only on a run-key change, notification pane lookup only when shown); WS reconnect jitter; terminal retry backoff 1.5 → 30 s, stops on 1008 - heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame; GET in-flight dedupe in request() - lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi (only while enabled); only the active locale loads; Workspace chunk prefetched on a /workspace deep link - Sagi: no work while disabled, pupil via CSS variables, memo avatar - build: .br/.gz siblings, sourcemap hidden; service worker no longer caches /assets/ (cache v3 drops the old one) Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook exports, so a suite run from a linked worktree's pre-commit can no longer commit into the real repository or set core.bare. Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README, docs/plans/perf/README.md (audit, checklist, results).
116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
/**
|
|
* @file vite.config.ts
|
|
* @description Vite build and dev-server configuration for the dashboard client — React plugin, an API/WebSocket proxy that honours DASHBOARD_PORT, build-time injection of the project version as `__APP_VERSION__`, one chunk per UI language, hidden source maps, and pre-compressed `<file>.br`/`<file>.gz` siblings of every text asset ≥ 1 KB (served by the server for `/assets/`).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { brotliCompressSync, constants, gzipSync } from "node:zlib";
|
|
import { defineConfig, type Plugin } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
|
|
// The dashboard's displayed version is the canonical project version from the
|
|
// repo-root package.json (the version CI cuts releases from), injected at build
|
|
// time as the `__APP_VERSION__` global so the UI footer always shows the real
|
|
// version instead of a hardcoded string. Vite runs from the client dir, so the
|
|
// root manifest is normally one level up; fall back to the client manifest, and
|
|
// finally a placeholder, so the build never fails when the root file is absent
|
|
// (e.g. a Docker stage that only copies client/). The global is declared in
|
|
// `client/src/vite-env.d.ts`.
|
|
function resolveAppVersion(): string {
|
|
for (const rel of ["../package.json", "package.json"]) {
|
|
try {
|
|
const { version } = JSON.parse(readFileSync(resolve(process.cwd(), rel), "utf8"));
|
|
if (version) return version as string;
|
|
} catch {
|
|
// Not found or unreadable at this path — try the next candidate.
|
|
}
|
|
}
|
|
return "0.0.0";
|
|
}
|
|
const APP_VERSION = resolveAppVersion();
|
|
|
|
// Honour DASHBOARD_PORT so the proxy follows when `npm run dev:server` is
|
|
// moved off the default 4820 (e.g. when an SSH `LocalForward` already holds
|
|
// 4820 on `127.0.0.1` and `::1`). The dev server reads the same env var from
|
|
// `server/index.js`, so a single `DASHBOARD_PORT=4821 npm run dev` keeps
|
|
// both sides in lockstep.
|
|
//
|
|
// We also target `127.0.0.1` rather than `localhost`: when several listeners
|
|
// exist on the same port across IP families (loopback-specific SSH binds vs.
|
|
// Node's wildcard listen), macOS routes connections by socket specificity,
|
|
// so `localhost` can resolve into the wrong process. An explicit IPv4 loopback
|
|
// is what the embedded server in production binds to anyway.
|
|
const DASHBOARD_PORT = parseInt(process.env.DASHBOARD_PORT || "4820", 10);
|
|
|
|
// Writes `<file>.br` (quality 11) and `<file>.gz` (level 9) next to every text
|
|
// asset of 1 KB or more once the build is on disk, so the server can send the
|
|
// smallest encoding without compressing per request. node:zlib only.
|
|
function precompress(): Plugin {
|
|
let outDir = "dist";
|
|
const walk = (dir: string): string[] =>
|
|
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
|
e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]
|
|
);
|
|
return {
|
|
name: "agenthub-precompress",
|
|
apply: "build",
|
|
configResolved(c) {
|
|
outDir = resolve(c.root, c.build.outDir);
|
|
},
|
|
closeBundle() {
|
|
for (const file of walk(outDir)) {
|
|
if (!/\.(js|css|svg|json|html)$/.test(file)) continue;
|
|
const buf = readFileSync(file);
|
|
if (buf.length < 1024) continue;
|
|
writeFileSync(
|
|
`${file}.br`,
|
|
brotliCompressSync(buf, {
|
|
params: {
|
|
[constants.BROTLI_PARAM_QUALITY]: 11,
|
|
[constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
|
|
},
|
|
})
|
|
);
|
|
writeFileSync(`${file}.gz`, gzipSync(buf, { level: 9 }));
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig({
|
|
plugins: [react(), precompress()],
|
|
define: {
|
|
__APP_VERSION__: JSON.stringify(APP_VERSION),
|
|
},
|
|
server: {
|
|
port: 5173,
|
|
proxy: {
|
|
"/api": {
|
|
target: `http://127.0.0.1:${DASHBOARD_PORT}`,
|
|
changeOrigin: true,
|
|
},
|
|
"/ws": {
|
|
target: `ws://127.0.0.1:${DASHBOARD_PORT}`,
|
|
ws: true,
|
|
},
|
|
},
|
|
},
|
|
build: {
|
|
outDir: "dist",
|
|
// Maps are still written (for debugging a build), but no bundle points at
|
|
// them, so a browser never downloads them.
|
|
sourcemap: "hidden",
|
|
rollupOptions: {
|
|
output: {
|
|
// i18n/index.ts loads locale files lazily: one chunk per language.
|
|
manualChunks(id) {
|
|
const m = /[\\/]i18n[\\/]locales[\\/](\w+)[\\/]/.exec(id);
|
|
return m ? `locale-${m[1]}` : undefined;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|