57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
220 lines
9.3 KiB
TypeScript
220 lines
9.3 KiB
TypeScript
/**
|
|
* @file Dashboard window creation + state persistence.
|
|
*
|
|
* We persist size/position to a JSON file under `app.getPath('userData')`.
|
|
* Avoids the `electron-window-state` dependency for ~30 lines of code.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
* =============================================================================
|
|
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
|
*
|
|
* ## Design constraints
|
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
*
|
|
* ## Remote data & SSH
|
|
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
*
|
|
* ## Observability
|
|
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
*
|
|
* ## Internal dependencies
|
|
* - `./constants`
|
|
* - `./logger`
|
|
*
|
|
* ## Public surface
|
|
* - `appIconPath` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `createDashboardWindow` — exported API; see TSDoc on the symbol for behavior.
|
|
*
|
|
* ## Testing pointers
|
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
|
*
|
|
* ## Related docs
|
|
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
* - `docs/API.md` — REST reference.
|
|
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
|
* ============================================================================= */
|
|
/* -----------------------------------------------------------------------------
|
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
* -----------------------------------------------------------------------------
|
|
* **appIconPath**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* **createDashboardWindow**
|
|
* Part of this module's public contract. Downstream imports should treat
|
|
* the signature and return type as stable unless release notes say otherwise.
|
|
* When behavior changes, update the `@file` overview and relevant tests.
|
|
*
|
|
* ----------------------------------------------------------------------------- */
|
|
|
|
import { BrowserWindow, app, shell } from "electron";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
|
|
import { APP_NAME, DEFAULT_WINDOW } from "./constants";
|
|
import { log } from "./logger";
|
|
|
|
/** Persisted window geometry. `x`/`y` are omitted until the window has been
|
|
* moved at least once — a fresh install lets Electron pick the OS default
|
|
* placement rather than forcing `(0, 0)`. */
|
|
interface WindowState {
|
|
width: number;
|
|
height: number;
|
|
x?: number;
|
|
y?: number;
|
|
}
|
|
|
|
/** Absolute path to the JSON file geometry is persisted to, under this
|
|
* platform's `userData` directory (e.g. `~/Library/Application Support/…`
|
|
* on macOS, `%APPDATA%` on Windows). */
|
|
function statePath(): string {
|
|
return path.join(app.getPath("userData"), "window-state.json");
|
|
}
|
|
|
|
/**
|
|
* Absolute path to the colored application icon used for the window title bar
|
|
* and the Windows taskbar / Linux launcher — the same logo the macOS app shows
|
|
* in its Dock (rendered from `assets/icon.svg`). Without this, an unpackaged
|
|
* `electron out/main.js` run falls back to the generic Electron icon.
|
|
*
|
|
* Windows wants the multi-size `.ico` (crisp at every taskbar scale); other
|
|
* platforms take the `.png`. macOS ignores `BrowserWindow#icon` entirely (its
|
|
* window has no icon and the Dock uses the bundle's `.icns`), so the value is
|
|
* harmless there. Resolves dev (`desktop/assets`) vs packaged
|
|
* (`Resources/assets`, shipped via `extraResources`); returns `undefined` if
|
|
* the file is absent so we cleanly fall back instead of throwing.
|
|
*/
|
|
export function appIconPath(): string | undefined {
|
|
const file = process.platform === "win32" ? "icon.ico" : "icon.png";
|
|
const base = app.isPackaged
|
|
? path.join(process.resourcesPath, "assets")
|
|
: path.join(__dirname, "..", "assets");
|
|
const p = path.join(base, file);
|
|
return fs.existsSync(p) ? p : undefined;
|
|
}
|
|
|
|
/**
|
|
* Read the persisted window geometry, falling back field-by-field to
|
|
* `DEFAULT_WINDOW` (and to `undefined` for position) whenever the file is
|
|
* missing, unreadable, or contains a field of the wrong type — so a
|
|
* corrupted or partially-written state file degrades gracefully instead of
|
|
* preventing the window from opening at all.
|
|
*/
|
|
function loadState(): WindowState {
|
|
try {
|
|
const raw = fs.readFileSync(statePath(), "utf8");
|
|
const parsed = JSON.parse(raw) as Partial<WindowState>;
|
|
return {
|
|
width: typeof parsed.width === "number" ? parsed.width : DEFAULT_WINDOW.width,
|
|
height: typeof parsed.height === "number" ? parsed.height : DEFAULT_WINDOW.height,
|
|
x: typeof parsed.x === "number" ? parsed.x : undefined,
|
|
y: typeof parsed.y === "number" ? parsed.y : undefined,
|
|
};
|
|
} catch {
|
|
return { width: DEFAULT_WINDOW.width, height: DEFAULT_WINDOW.height };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write the window's current bounds to `statePath()`. Skipped while the
|
|
* window is destroyed or minimized, since `getBounds()` on a minimized
|
|
* window reports the pre-minimize size on some platforms — persisting it
|
|
* would silently discard the user's last real resize/move. Failures (e.g.
|
|
* a read-only `userData` dir) are logged, not thrown — losing the saved
|
|
* geometry is cosmetic, not fatal.
|
|
*/
|
|
function saveState(win: BrowserWindow): void {
|
|
if (win.isDestroyed() || win.isMinimized()) return;
|
|
const { width, height, x, y } = win.getBounds();
|
|
try {
|
|
fs.writeFileSync(statePath(), JSON.stringify({ width, height, x, y }));
|
|
} catch (err) {
|
|
log.warn("could not persist window state", err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create the single dashboard `BrowserWindow` and point it at the embedded
|
|
* server's origin. Restores the last persisted size/position (see
|
|
* `loadState`), re-saves it (debounced) on every resize/move/close, routes
|
|
* all external navigation to the system browser instead of inside Electron,
|
|
* and defers `show()` until `ready-to-show` so the window never flashes an
|
|
* unstyled blank frame while the page loads.
|
|
*
|
|
* @param targetUrl The embedded server's origin, e.g. `http://127.0.0.1:4820`.
|
|
* @returns The newly created, not-yet-visible `BrowserWindow`.
|
|
*/
|
|
export function createDashboardWindow(targetUrl: string): BrowserWindow {
|
|
const state = loadState();
|
|
|
|
const win = new BrowserWindow({
|
|
width: state.width,
|
|
height: state.height,
|
|
x: state.x,
|
|
y: state.y,
|
|
minWidth: 720,
|
|
minHeight: 480,
|
|
show: false,
|
|
title: APP_NAME,
|
|
// Colored app logo for the title bar + taskbar (matches the macOS Dock
|
|
// icon). No-op on macOS; falls through to the Electron default if missing.
|
|
icon: appIconPath(),
|
|
// Use the standard macOS title bar rather than `hiddenInset`. With a hidden
|
|
// title bar the traffic-light buttons float directly over the React app's
|
|
// top edge and visually blend into the dashboard chrome; a native title bar
|
|
// gives them their own clearly-separated row, shows the app name, and
|
|
// restores the conventional double-click-to-maximize / drag-from-anywhere
|
|
// behaviour without needing custom drag regions in the renderer.
|
|
titleBarStyle: "default",
|
|
backgroundColor: "#0b0f1a",
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.js"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: false,
|
|
// We're loading our own localhost-only origin, never remote content.
|
|
webSecurity: true,
|
|
},
|
|
});
|
|
|
|
win.once("ready-to-show", () => win.show());
|
|
|
|
// Persist size/position on resize/move (debounced via the close handler too).
|
|
let saveTimer: NodeJS.Timeout | null = null;
|
|
const debounced = () => {
|
|
if (saveTimer) clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(() => saveState(win), 400);
|
|
};
|
|
win.on("resize", debounced);
|
|
win.on("move", debounced);
|
|
win.on("close", () => saveState(win));
|
|
|
|
// External links open in the user's browser, not inside Electron.
|
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
void shell.openExternal(url);
|
|
return { action: "deny" };
|
|
});
|
|
win.webContents.on("will-navigate", (event, url) => {
|
|
if (!url.startsWith(targetUrl)) {
|
|
event.preventDefault();
|
|
void shell.openExternal(url);
|
|
}
|
|
});
|
|
|
|
win.loadURL(targetUrl).catch((err) => log.error("failed to load dashboard URL", err));
|
|
return win;
|
|
}
|