feat(dashboard,desktop): show live database-migration progress during boot
The one-time SQLite→PostgreSQL migration runs inside createTaskStoreForBackend before any HTTP server listens, so browsers saw "connection refused" and open tabs failed silently for minutes. Now: - CLI: a temporary holding server binds the dashboard port for the boot window, serving an auto-reloading "Database migration in progress" page and an /api/health payload with status "migrating" + structured progress; the port is handed off (awaited) to the real app.listen(). - Dashboard SPA: already-open tabs render the new MigrationInProgressBanner from the 15s health poll when status is "migrating". - Desktop: LocalRuntimeManager publishes migration progress on DesktopRuntimeStatus via the new core onMigrationProgress option; DesktopLaunchGate shows the live label and extends its 30s startup timeout while progress advances (2min stall cap), in both boot and first-run flows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/migration-holding-banner.md
Normal file
7
.changeset/migration-holding-banner.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Show live database-migration progress during boot — dashboard holding page/banner and desktop launch screen.
|
||||
category: feature
|
||||
dev: CLI binds a temporary holding server on the dashboard port during `createTaskStoreForBackend` (new `onMigrationProgress` option) serving an auto-reloading page + `/api/health` `status:"migrating"`; open tabs render `MigrationInProgressBanner` from the health poll. Desktop publishes progress via `DesktopRuntimeStatus.migration` → IPC → `DesktopLaunchGate`, which shows the label and suspends its 30s timeout while progress advances.
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:50:
|
||||
The boot-window holding server is the only surface a browser can reach while
|
||||
the SQLite→PostgreSQL auto-migration blocks dashboard boot. These tests pin its
|
||||
contract: health JSON (starting → migrating with a progress label), HTML
|
||||
holding page with 503 for navigations, JSON 503 for other API calls, and a
|
||||
close() that fully releases the port for the real app.listen().
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { startMigrationHoldingServer } from "../migration-holding-server.js";
|
||||
import type { MigrationProgressEvent } from "@fusion/core";
|
||||
|
||||
const HOST = "127.0.0.1";
|
||||
|
||||
async function withServer(
|
||||
run: (server: NonNullable<Awaited<ReturnType<typeof startMigrationHoldingServer>>>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const server = await startMigrationHoldingServer({ port: 0, host: HOST });
|
||||
expect(server).not.toBeNull();
|
||||
try {
|
||||
await run(server!);
|
||||
} finally {
|
||||
await server!.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe("startMigrationHoldingServer", () => {
|
||||
it("reports starting, then migrating with a progress label on /api/health", async () => {
|
||||
await withServer(async (server) => {
|
||||
const before = await fetch(`http://${HOST}:${server.port}/api/health`);
|
||||
expect(before.status).toBe(200);
|
||||
const beforeBody = await before.json();
|
||||
expect(beforeBody.status).toBe("starting");
|
||||
expect(beforeBody.migration).toEqual({ active: false });
|
||||
|
||||
const event: MigrationProgressEvent = {
|
||||
phase: "table-progress",
|
||||
sourceSchema: "project",
|
||||
table: "tasks",
|
||||
tableIndex: 3,
|
||||
tableCount: 12,
|
||||
processedRows: 500,
|
||||
sourceRows: 2000,
|
||||
};
|
||||
server.setMigrationProgress(event);
|
||||
|
||||
const after = await fetch(`http://${HOST}:${server.port}/api/health`);
|
||||
const afterBody = await after.json();
|
||||
expect(afterBody.status).toBe("migrating");
|
||||
expect(afterBody.holding).toBe(true);
|
||||
expect(afterBody.migration.active).toBe(true);
|
||||
expect(afterBody.migration.table).toBe("tasks");
|
||||
expect(afterBody.migration.processedRows).toBe(500);
|
||||
expect(afterBody.migration.sourceRows).toBe(2000);
|
||||
expect(typeof afterBody.migration.label).toBe("string");
|
||||
expect(afterBody.migration.label).toContain("tasks");
|
||||
});
|
||||
});
|
||||
|
||||
it("serves a 503 HTML holding page for navigations and 503 JSON for other API calls", async () => {
|
||||
await withServer(async (server) => {
|
||||
const page = await fetch(`http://${HOST}:${server.port}/`);
|
||||
expect(page.status).toBe(503);
|
||||
expect(page.headers.get("content-type")).toContain("text/html");
|
||||
const html = await page.text();
|
||||
expect(html).toContain("migration");
|
||||
expect(html).toContain("/api/health");
|
||||
|
||||
const api = await fetch(`http://${HOST}:${server.port}/api/tasks`);
|
||||
expect(api.status).toBe(503);
|
||||
expect(api.headers.get("content-type")).toContain("application/json");
|
||||
const body = await api.json();
|
||||
expect(typeof body.error).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the port on close so the real server can bind", async () => {
|
||||
const server = await startMigrationHoldingServer({ port: 0, host: HOST });
|
||||
expect(server).not.toBeNull();
|
||||
const port = server!.port;
|
||||
await server!.close();
|
||||
// close() is idempotent.
|
||||
await server!.close();
|
||||
await expect(fetch(`http://${HOST}:${port}/api/health`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("fails soft (returns null) when the port is already taken", async () => {
|
||||
const first = await startMigrationHoldingServer({ port: 0, host: HOST });
|
||||
expect(first).not.toBeNull();
|
||||
try {
|
||||
const messages: string[] = [];
|
||||
const second = await startMigrationHoldingServer({
|
||||
port: first!.port,
|
||||
host: HOST,
|
||||
log: (message) => messages.push(message),
|
||||
});
|
||||
expect(second).toBeNull();
|
||||
expect(messages.some((m) => m.includes("holding page not available"))).toBe(true);
|
||||
} finally {
|
||||
await first!.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
createPrReconcileGithubOps,
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { startMigrationHoldingServer } from "./migration-holding-server.js";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
@@ -893,9 +894,27 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
optional SQLite migrate) is the first large bucket after the PostgreSQL cutover.
|
||||
*/
|
||||
const logPhase = (message: string, scope = "dashboard") => logSink.log(message, scope);
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:30:
|
||||
The backend factory below performs the one-time SQLite→PostgreSQL migration
|
||||
BEFORE the real HTTP server binds, so browsers hitting the dashboard during
|
||||
that window previously saw "connection refused". Bind a temporary holding
|
||||
server on the selected port for the boot window: fresh navigations get a
|
||||
live "database migration in progress" page and already-open dashboard tabs
|
||||
see a "migrating" /api/health status they render as a banner. The port is
|
||||
released (awaited) right before the real app.listen(). Bind failure is soft.
|
||||
*/
|
||||
const migrationHoldingServer = await startMigrationHoldingServer({
|
||||
port: selectedPort,
|
||||
host: selectedHost,
|
||||
log: (message) => logSink.log(message, "dashboard"),
|
||||
});
|
||||
const dashboardBackendBoot = await phaseTime(
|
||||
"backend.factory",
|
||||
() => createTaskStoreForBackend({ rootDir: cwd }),
|
||||
() => createTaskStoreForBackend({
|
||||
rootDir: cwd,
|
||||
onMigrationProgress: (event) => migrationHoldingServer?.setMigrationProgress(event),
|
||||
}),
|
||||
logPhase,
|
||||
);
|
||||
// FNXC:PostgresFinalCutover 2026-07-14-17:20: Dashboard runtime storage is
|
||||
@@ -2704,6 +2723,15 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
}, POLL_MS).unref();
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:30:
|
||||
Release the boot-window holding server before binding the real server on the
|
||||
same port. The close is awaited so the ports never race; the holding page
|
||||
keeps polling through the swap and reloads into the real dashboard.
|
||||
*/
|
||||
if (migrationHoldingServer) {
|
||||
await migrationHoldingServer.close();
|
||||
}
|
||||
const server = app.listen(selectedPort, selectedHost);
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
|
||||
189
packages/cli/src/commands/migration-holding-server.ts
Normal file
189
packages/cli/src/commands/migration-holding-server.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:25:
|
||||
The dashboard HTTP server only starts listening AFTER createTaskStoreForBackend
|
||||
resolves, and that call performs the one-time SQLite→PostgreSQL auto-migration,
|
||||
which can copy hundreds of thousands of rows and take minutes. During that window
|
||||
the dashboard port was simply closed: a browser navigating to the dashboard saw
|
||||
"connection refused" and an already-open dashboard tab silently failed its
|
||||
fetches with no explanation.
|
||||
|
||||
This module binds a tiny temporary HTTP server on the dashboard port for the
|
||||
boot window and releases it just before the real server's app.listen():
|
||||
- Any page request gets a self-contained 503 holding page ("Database migration
|
||||
in progress") that polls /api/health and reloads itself into the real
|
||||
dashboard once boot completes.
|
||||
- GET /api/health returns 200 JSON with status "starting" (no migration seen
|
||||
yet) or "migrating" plus a structured progress snapshot. Already-open
|
||||
dashboard tabs poll /api/health every 15s (useDashboardHealth) and use the
|
||||
"migrating" status to show the MigrationInProgressBanner instead of failing
|
||||
silently. The payload intentionally omits engine/database/taskIdIntegrity so
|
||||
the frontend's optional-chained banner gates stay quiet during boot.
|
||||
- All other /api/* requests get 503 JSON so in-flight app fetches fail cleanly
|
||||
instead of parsing HTML.
|
||||
|
||||
Bind failures (e.g. port already in use) are soft: boot proceeds without the
|
||||
holding page and the existing app.listen EADDRINUSE fallback still applies.
|
||||
*/
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { formatMigrationProgress, type MigrationProgressEvent } from "@fusion/core";
|
||||
|
||||
export interface MigrationHoldingServer {
|
||||
/** The actual bound port (useful when 0 was requested in tests). */
|
||||
readonly port: number;
|
||||
/** Record the latest structured migration event for the health payload/page. */
|
||||
setMigrationProgress(event: MigrationProgressEvent): void;
|
||||
/** Release the port. Resolves once every connection is torn down. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface StartOptions {
|
||||
readonly port: number;
|
||||
readonly host: string;
|
||||
readonly log?: (message: string) => void;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:25:
|
||||
The holding page must be fully self-contained (no dashboard bundle is served
|
||||
yet), dark, and on-brand (all-blue). It polls /api/health every 1.5s: while the
|
||||
status is "starting"/"migrating" it updates the progress line; once any other
|
||||
status (the real server) answers it reloads into the real dashboard. Repeated
|
||||
fetch failures flip the copy to a "cannot reach Fusion" hint so a crashed boot
|
||||
does not masquerade as a forever-running migration.
|
||||
*/
|
||||
function renderHoldingPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Fusion — starting</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: #0b0e14; color: #e6e9ef;
|
||||
font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.card { max-width: 30rem; padding: 2rem; text-align: center; }
|
||||
.spinner {
|
||||
width: 2.25rem; height: 2.25rem; margin: 0 auto 1.25rem;
|
||||
border: 3px solid rgba(59, 130, 246, 0.25); border-top-color: #3b82f6;
|
||||
border-radius: 50%; animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
h1 { font-size: 1.15rem; margin: 0 0 0.5rem; font-weight: 600; }
|
||||
p { margin: 0 0 0.75rem; color: #9aa4b2; }
|
||||
.progress { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; color: #7fb1ff; min-height: 1.5em; overflow-wrap: anywhere; }
|
||||
.offline { color: #f0883e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" role="status" aria-live="polite">
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<h1 id="title">Fusion is starting…</h1>
|
||||
<p id="subtitle">If a database migration is needed it runs now and can take a few minutes for large projects. This page reloads automatically when the dashboard is ready.</p>
|
||||
<div class="progress" id="progress"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var failures = 0;
|
||||
var sawMigration = false;
|
||||
async function poll() {
|
||||
try {
|
||||
var res = await fetch("/api/health", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
failures = 0;
|
||||
var data = await res.json();
|
||||
if (data && (data.status === "starting" || data.status === "migrating")) {
|
||||
if (data.status === "migrating") {
|
||||
sawMigration = true;
|
||||
document.getElementById("title").textContent = "Database migration in progress";
|
||||
}
|
||||
var label = data.migration && data.migration.label;
|
||||
if (label) document.getElementById("progress").textContent = label;
|
||||
return;
|
||||
}
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
failures += 1;
|
||||
if (failures >= 8) {
|
||||
document.getElementById("progress").innerHTML =
|
||||
'<span class="offline">Cannot reach Fusion — it may have stopped. Check the terminal, then reload.</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
poll();
|
||||
setInterval(poll, 1500);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function startMigrationHoldingServer(
|
||||
options: StartOptions,
|
||||
): Promise<MigrationHoldingServer | null> {
|
||||
let latest: MigrationProgressEvent | null = null;
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
const url = req.url ?? "/";
|
||||
if (url === "/api/health" || url.startsWith("/api/health?")) {
|
||||
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({
|
||||
status: latest ? "migrating" : "starting",
|
||||
holding: true,
|
||||
migration: latest
|
||||
? {
|
||||
active: true,
|
||||
phase: latest.phase,
|
||||
label: formatMigrationProgress(latest),
|
||||
...("table" in latest ? { table: latest.table, tableIndex: latest.tableIndex, tableCount: latest.tableCount } : {}),
|
||||
...(latest.phase === "table-progress" ? { processedRows: latest.processedRows, sourceRows: latest.sourceRows } : {}),
|
||||
}
|
||||
: { active: false },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (url.startsWith("/api/")) {
|
||||
res.writeHead(503, { "content-type": "application/json", "retry-after": "2" });
|
||||
res.end(JSON.stringify({ error: "Fusion is starting (database migration may be in progress)" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "2", "cache-control": "no-store" });
|
||||
res.end(renderHoldingPage());
|
||||
});
|
||||
|
||||
const bound = await new Promise<boolean>((resolve) => {
|
||||
server.once("error", (error: NodeJS.ErrnoException) => {
|
||||
options.log?.(`migration holding page not available (${error.code ?? error.message}); continuing boot without it`);
|
||||
resolve(false);
|
||||
});
|
||||
server.listen(options.port, options.host, () => resolve(true));
|
||||
});
|
||||
if (!bound) return null;
|
||||
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address !== null ? address.port : options.port;
|
||||
|
||||
let closed: Promise<void> | null = null;
|
||||
return {
|
||||
port,
|
||||
setMigrationProgress(event: MigrationProgressEvent): void {
|
||||
latest = event;
|
||||
},
|
||||
close(): Promise<void> {
|
||||
if (closed) return closed;
|
||||
closed = new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:25:
|
||||
Keep-alive sockets from the polling page would otherwise hold the port
|
||||
open past close() and race the real app.listen() on the same port.
|
||||
*/
|
||||
server.closeAllConnections();
|
||||
});
|
||||
return closed;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -427,6 +427,15 @@ export interface CreateTaskStoreForBackendOptions {
|
||||
* constructor (matching `new TaskStore(rootDir)`).
|
||||
*/
|
||||
readonly projectId?: string;
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:20:
|
||||
During the one-time SQLite→PostgreSQL auto-migration the caller's HTTP server is
|
||||
not yet listening, so the CLI binds a temporary holding server on the dashboard
|
||||
port and needs the structured migration progress stream to surface it in the
|
||||
browser. This observer receives the same MigrationProgressEvent stream that is
|
||||
logged to the terminal; it must never throw (fire-and-forget UI plumbing).
|
||||
*/
|
||||
readonly onMigrationProgress?: (event: import("./sqlite-migrator.js").MigrationProgressEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -651,6 +660,13 @@ export async function createTaskStoreForBackend(
|
||||
*/
|
||||
onProgress: (event) => {
|
||||
log.log(`startup-factory: SQLite migration — ${formatMigrationProgress(event)}`);
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:20:
|
||||
Forward the same structured event to the caller (CLI holding server)
|
||||
so the browser can show live migration progress; observer failures
|
||||
must never abort the migration itself.
|
||||
*/
|
||||
try { options.onMigrationProgress?.(event); } catch { /* ignore observer errors */ }
|
||||
},
|
||||
});
|
||||
/*
|
||||
|
||||
@@ -26,6 +26,26 @@ export interface DashboardHealthResponse {
|
||||
status: string;
|
||||
version: string;
|
||||
uptime: number;
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:35:
|
||||
While the CLI's boot-window holding server owns the dashboard port (real
|
||||
server not yet listening), /api/health answers with status "migrating" (or
|
||||
"starting") and this progress snapshot — and OMITS engine/database/
|
||||
taskIdIntegrity. Consumers of those fields must optional-chain (the
|
||||
DashboardBanners gates already do) so boot-window polls don't fire the
|
||||
engine/db-corruption banners. The real server never sets `migration`.
|
||||
*/
|
||||
holding?: boolean;
|
||||
migration?: {
|
||||
active: boolean;
|
||||
phase?: string;
|
||||
label?: string;
|
||||
table?: string;
|
||||
tableIndex?: number;
|
||||
tableCount?: number;
|
||||
processedRows?: number;
|
||||
sourceRows?: number;
|
||||
};
|
||||
engine?: {
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
@@ -40,3 +40,16 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-13:35:
|
||||
Monospace progress line under the "Database migration in progress" gate copy —
|
||||
carries the migrator's structured "[3/12] project.tasks — 500/2000 rows" label.
|
||||
*/
|
||||
.desktop-launch-gate__detail {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
overflow-wrap: anywhere;
|
||||
margin: -0.75rem 0 1.25rem;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import "./DesktopLaunchGate.css";
|
||||
type Phase =
|
||||
| { kind: "loading" }
|
||||
| { kind: "chooser"; state: ShellConnectionState }
|
||||
| { kind: "starting-local"; message: string }
|
||||
| { kind: "starting-local"; message: string; detail?: string }
|
||||
| { kind: "local-error"; message: string }
|
||||
| { kind: "ready"; serverBaseUrl?: string }
|
||||
| { kind: "bypass" };
|
||||
@@ -28,11 +28,27 @@ function needsChooser(state: ShellConnectionState): boolean {
|
||||
return !state.desktopMode;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-13:30:
|
||||
While the first-boot SQLite→PostgreSQL migration runs inside the embedded
|
||||
runtime start, `localRuntime.migration` carries live progress. Two duties here:
|
||||
(1) report each new progress label so the gate can replace the static
|
||||
"Starting…" copy with real migration status, and (2) push the startup deadline
|
||||
out while progress advances — large databases legitimately exceed the 30s
|
||||
default and must not surface the "did not become ready in time" error screen
|
||||
mid-migration. The extension keys on the LABEL CHANGING (not merely
|
||||
migration.active) so a genuinely hung migration still times out, after
|
||||
MIGRATION_STALL_TIMEOUT_MS of no new progress.
|
||||
*/
|
||||
const MIGRATION_STALL_TIMEOUT_MS = 120_000;
|
||||
|
||||
async function waitForLocalRuntime(
|
||||
shell: NonNullable<ReturnType<typeof getFusionShell>>,
|
||||
timeoutMs = 30_000,
|
||||
options: { timeoutMs?: number; onMigrationProgress?: (label: string) => void } = {},
|
||||
): Promise<{ baseUrl: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
let deadline = Date.now() + timeoutMs;
|
||||
let lastMigrationLabel: string | null = null;
|
||||
// The runtime is started by main when setDesktopMode("local") fires; just
|
||||
// poll shell:getState until localRuntime reports running.
|
||||
while (Date.now() < deadline) {
|
||||
@@ -45,6 +61,12 @@ async function waitForLocalRuntime(
|
||||
if (rt?.state === "error") {
|
||||
throw new Error(rt.error ?? "Local runtime failed to start");
|
||||
}
|
||||
const migrationLabel = rt?.migration?.active ? rt.migration.label ?? null : null;
|
||||
if (migrationLabel && migrationLabel !== lastMigrationLabel) {
|
||||
lastMigrationLabel = migrationLabel;
|
||||
deadline = Math.max(deadline, Date.now() + MIGRATION_STALL_TIMEOUT_MS);
|
||||
options.onMigrationProgress?.(migrationLabel);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error("Local runtime did not become ready in time");
|
||||
@@ -144,7 +166,17 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
}
|
||||
if (cancelled) return;
|
||||
}
|
||||
const { baseUrl } = await waitForLocalRuntime(shell);
|
||||
const { baseUrl } = await waitForLocalRuntime(shell, {
|
||||
/* FNXC:MigrationHoldingPage 2026-07-17-13:30: swap the static "Starting…" copy for live migration progress while the first-boot database migration runs. */
|
||||
onMigrationProgress: (label) => {
|
||||
if (cancelled) return;
|
||||
setPhase({
|
||||
kind: "starting-local",
|
||||
message: t("desktop.migrationInProgress", "Database migration in progress — this can take a few minutes."),
|
||||
detail: label,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
navigateToLocalRuntimeOrigin(baseUrl);
|
||||
return;
|
||||
@@ -188,10 +220,12 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
|
||||
if (phase.kind === "loading" || phase.kind === "starting-local") {
|
||||
const message = phase.kind === "loading" ? t("desktop.loading", "Loading Fusion…") : phase.message;
|
||||
const detail = phase.kind === "starting-local" ? phase.detail : undefined;
|
||||
return (
|
||||
<div className="desktop-launch-gate" role="status" aria-live="polite">
|
||||
<div className="desktop-launch-gate__panel">
|
||||
<p>{message}</p>
|
||||
{detail ? <p className="desktop-launch-gate__detail">{detail}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -230,7 +264,15 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) {
|
||||
try {
|
||||
await shell.setDesktopMode(mode);
|
||||
if (mode === "local") {
|
||||
const { baseUrl } = await waitForLocalRuntime(shell);
|
||||
const { baseUrl } = await waitForLocalRuntime(shell, {
|
||||
/* FNXC:MigrationHoldingPage 2026-07-17-13:30: first "Run Fusion Locally" pick is exactly when the one-time database migration runs — show its progress. */
|
||||
onMigrationProgress: (label) =>
|
||||
setPhase({
|
||||
kind: "starting-local",
|
||||
message: t("desktop.migrationInProgress", "Database migration in progress — this can take a few minutes."),
|
||||
detail: label,
|
||||
}),
|
||||
});
|
||||
navigateToLocalRuntimeOrigin(baseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:40:
|
||||
Mirrors the TestModeBanner shape (left accent bar + tinted background) but uses
|
||||
the info color: a running migration is an informational transient state, not a
|
||||
warning. The monospace progress line carries the migrator's structured label.
|
||||
*/
|
||||
.migration-in-progress-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border-inline-start: var(--space-xs) solid var(--color-info);
|
||||
background: color-mix(in srgb, var(--color-info) 18%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.migration-in-progress-banner-progress {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 768px), (max-height: 480px) {
|
||||
.migration-in-progress-banner {
|
||||
padding: var(--space-sm);
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:40:
|
||||
When the Fusion server restarts and performs the one-time SQLite→PostgreSQL
|
||||
migration, an already-open dashboard tab keeps polling /api/health (every 15s
|
||||
via useDashboardHealth) and reaches the CLI's boot-window holding server, which
|
||||
answers with status "migrating" plus a progress label. This banner surfaces
|
||||
that state so the open tab explains the outage instead of failing silently.
|
||||
It renders from health status alone (no project gate — during the boot window
|
||||
no project data is fetchable) and disappears on the next poll of the real
|
||||
server. Fresh navigations during migration get the holding page instead.
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DatabaseZap } from "lucide-react";
|
||||
import "./MigrationInProgressBanner.css";
|
||||
|
||||
interface MigrationInProgressBannerProps {
|
||||
isActive: boolean;
|
||||
progressLabel?: string;
|
||||
}
|
||||
|
||||
export function MigrationInProgressBanner({ isActive, progressLabel }: MigrationInProgressBannerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="migration-in-progress-banner" role="status" aria-live="polite">
|
||||
<DatabaseZap aria-hidden="true" />
|
||||
<span>
|
||||
{t("app.migrationInProgress", "Database migration in progress — the dashboard will reconnect when it completes.")}
|
||||
{progressLabel ? (
|
||||
<span className="migration-in-progress-banner-progress">{progressLabel}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,47 @@ describe("DesktopLaunchGate — local handoff", () => {
|
||||
expect(shell.setDesktopMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:MigrationHoldingPage 2026-07-17-13:45:
|
||||
* While localRuntime reports state "starting" with migration progress, the gate
|
||||
* must show the migration copy + the structured progress label instead of the
|
||||
* static "Starting local Fusion runtime…", then still hand off once running.
|
||||
*/
|
||||
it("shows live migration progress while starting, then navigates when running", async () => {
|
||||
const location = stubLocation("file:///C:/app/index.html");
|
||||
let migrationDone = false;
|
||||
const migrating = {
|
||||
...localReadyState,
|
||||
localRuntime: {
|
||||
source: "embedded-local",
|
||||
state: "starting",
|
||||
migration: { active: true, phase: "table-progress", label: "[3/12] project.tasks — 500/2000 rows" },
|
||||
},
|
||||
};
|
||||
const shell = {
|
||||
getState: vi.fn(async () => (migrationDone ? localReadyState : migrating)),
|
||||
setDesktopMode: vi.fn(async () => migrating),
|
||||
onResetDesktopModeRequest: vi.fn(() => () => undefined),
|
||||
resetDesktopMode: vi.fn(async () => undefined),
|
||||
};
|
||||
(window as unknown as { fusionShell: unknown }).fusionShell = shell;
|
||||
|
||||
render(
|
||||
<DesktopLaunchGate>
|
||||
<div data-testid="app-loaded">app</div>
|
||||
</DesktopLaunchGate>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Database migration in progress"),
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("[3/12] project.tasks — 500/2000 rows");
|
||||
|
||||
migrationDone = true;
|
||||
await waitFor(() => expect(location.replace).toHaveBeenCalledTimes(1));
|
||||
expect(location.replace.mock.calls[0][0]).toMatch(/^http:\/\/127\.0\.0\.1:50123\//);
|
||||
});
|
||||
|
||||
it("starts the runtime when it is not running, then navigates to its origin", async () => {
|
||||
const location = stubLocation("file:///C:/app/index.html");
|
||||
// First getState: stopped. setDesktopMode starts it; subsequent polls: running.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-12:50:
|
||||
Pins the open-tab migration banner contract: hidden unless health reports
|
||||
status "migrating", and shows the structured progress label when present.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MigrationInProgressBanner } from "../MigrationInProgressBanner";
|
||||
|
||||
describe("MigrationInProgressBanner", () => {
|
||||
it("renders nothing when inactive", () => {
|
||||
const { container } = render(<MigrationInProgressBanner isActive={false} progressLabel="ignored" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders status copy when active", () => {
|
||||
render(<MigrationInProgressBanner isActive />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Database migration in progress");
|
||||
});
|
||||
|
||||
it("shows the structured progress label when provided", () => {
|
||||
render(<MigrationInProgressBanner isActive progressLabel="[3/12] project.tasks — 500/2000 rows" />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("[3/12] project.tasks — 500/2000 rows");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ DashboardBanners is the conditional banner cluster rendered above the dashboard-
|
||||
import type { DashboardBannersProps } from "./types";
|
||||
import type { SectionId } from "../SettingsModal";
|
||||
import { TestModeBanner } from "../TestModeBanner";
|
||||
import { MigrationInProgressBanner } from "../MigrationInProgressBanner";
|
||||
import { SqliteMigrationBanner } from "../SqliteMigrationBanner";
|
||||
import { EngineUnavailableBanner } from "../EngineUnavailableBanner";
|
||||
import { EngineStatusBanner } from "../EngineStatusBanner";
|
||||
@@ -76,6 +77,15 @@ export function DashboardBanners({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FNXC:MigrationHoldingPage 2026-07-17-12:45: Rendered OUTSIDE the
|
||||
project gate — while the boot-window holding server reports
|
||||
status "migrating", project data is not fetchable, yet the open tab
|
||||
must still explain the outage. Clears on the next health poll of the
|
||||
real server. */}
|
||||
<MigrationInProgressBanner
|
||||
isActive={dashboardHealth?.status === "migrating"}
|
||||
progressLabel={dashboardHealth?.migration?.label}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && (
|
||||
<>
|
||||
<TestModeBanner isActive={isTestMode} />
|
||||
|
||||
12
packages/dashboard/app/types/native-shell.d.ts
vendored
12
packages/dashboard/app/types/native-shell.d.ts
vendored
@@ -38,6 +38,18 @@ export interface ShellConnectionState {
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
error?: string;
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-13:30:
|
||||
Live SQLite→PostgreSQL migration progress published by the desktop
|
||||
LocalRuntimeManager while state is "starting" (packages/desktop/src/
|
||||
local-runtime.ts). DesktopLaunchGate renders it and suspends its startup
|
||||
timeout while progress advances.
|
||||
*/
|
||||
migration?: {
|
||||
active: boolean;
|
||||
phase?: string;
|
||||
label?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,57 @@ describe("LocalRuntimeManager", () => {
|
||||
expect(manager.getServerPort()).toBe(4545);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-13:40:
|
||||
Pins the desktop migration-progress contract: while createStore (which runs the
|
||||
one-time SQLite→PG migration) is in flight, progress published through the
|
||||
createStore callback must be visible on getStatus() as
|
||||
state:"starting" + migration, and a terminal "running" status must not carry it.
|
||||
*/
|
||||
it("publishes migration progress while starting and clears it when running", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
const server = new FakeServer(4550);
|
||||
let releaseStore: () => void = () => {};
|
||||
const storeGate = new Promise<void>((resolve) => {
|
||||
releaseStore = resolve;
|
||||
});
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async (_rootDir, onMigrationProgress) => {
|
||||
onMigrationProgress?.({
|
||||
active: true,
|
||||
phase: "table-progress",
|
||||
label: "[3/12] project.tasks — 500/2000 rows",
|
||||
});
|
||||
await storeGate;
|
||||
return store;
|
||||
},
|
||||
createDashboardServer: async () => {
|
||||
setTimeout(() => server.emit("listening"), 0);
|
||||
return server as unknown as Server;
|
||||
},
|
||||
});
|
||||
|
||||
const startPromise = manager.startLocal();
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
source: "embedded-local",
|
||||
state: "starting",
|
||||
migration: {
|
||||
active: true,
|
||||
phase: "table-progress",
|
||||
label: "[3/12] project.tasks — 500/2000 rows",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
releaseStore();
|
||||
const status = await startPromise;
|
||||
expect(status.state).toBe("running");
|
||||
expect(status.migration).toBeUndefined();
|
||||
expect(manager.getStatus().migration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns external-cli status without starting embedded runtime", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
const manager = new LocalRuntimeManager({
|
||||
|
||||
@@ -147,6 +147,8 @@ const mocks = vi.hoisted(() => {
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: mocks.TaskStore,
|
||||
createTaskStoreForBackend: mocks.createTaskStoreForBackend,
|
||||
/* FNXC:MigrationHoldingPage 2026-07-17-13:50: local-server.ts formats migration progress for the launch gate. */
|
||||
formatMigrationProgress: (event: { phase: string }) => `migration ${event.phase}`,
|
||||
CentralCore: mocks.CentralCore,
|
||||
PluginLoader: mocks.PluginLoader,
|
||||
ensureBundledPluginInstalled: mocks.ensureBundledPluginInstalled,
|
||||
|
||||
@@ -30,12 +30,29 @@ function strace(msg: string): void {
|
||||
export type RuntimeSource = "embedded-local" | "external-cli" | "none";
|
||||
export type RuntimeState = "stopped" | "starting" | "running" | "error";
|
||||
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-13:20:
|
||||
The one-time SQLite→PostgreSQL migration runs inside createTaskStoreForBackend
|
||||
BEFORE the embedded server listens, so during it the desktop window only shows
|
||||
the static "Starting local Fusion runtime…" gate. Surface structured migration
|
||||
progress through the runtime status → IPC getRuntimeStatus →
|
||||
fusionShell.getState().localRuntime → DesktopLaunchGate poll, which renders it
|
||||
and suspends its 30s startup timeout while progress advances. `label` is the
|
||||
same formatMigrationProgress() string the CLI logs.
|
||||
*/
|
||||
export interface DesktopMigrationProgress {
|
||||
active: boolean;
|
||||
phase: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DesktopRuntimeStatus {
|
||||
source: RuntimeSource;
|
||||
state: RuntimeState;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
error?: string;
|
||||
migration?: DesktopMigrationProgress;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -69,7 +86,7 @@ type RuntimeInstance = {
|
||||
export interface LocalRuntimeManagerOptions {
|
||||
rootDir: string;
|
||||
getExternalPort?: () => number | undefined;
|
||||
createStore?: (rootDir: string) => Promise<TaskStoreLike>;
|
||||
createStore?: (rootDir: string, onMigrationProgress?: (progress: DesktopMigrationProgress) => void) => Promise<TaskStoreLike>;
|
||||
createDashboardServer?: (store: TaskStoreLike, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
|
||||
/**
|
||||
* FNXC:DesktopRuntime 2026-07-05-00:00:
|
||||
@@ -95,15 +112,23 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
|
||||
async function createStoreDefault(
|
||||
rootDir: string,
|
||||
onMigrationProgress?: (progress: DesktopMigrationProgress) => void,
|
||||
): Promise<TaskStoreLike> {
|
||||
// FNXC:BackendFlip 2026-06-26-14:40:
|
||||
// Consult the startup factory to boot a PostgreSQL-backed TaskStore. Post
|
||||
// default-flip: the factory boots embedded PG by default when DATABASE_URL
|
||||
// is unset and external PG when DATABASE_URL is set. The backend shutdown handle is stashed on the returned object so
|
||||
// the runtime manager's stop path can release the pool / stop an embedded
|
||||
// cluster.
|
||||
const { createTaskStoreForBackend } = await import("@fusion/core");
|
||||
const backendBoot = await createTaskStoreForBackend({ rootDir });
|
||||
const { createTaskStoreForBackend, formatMigrationProgress } = await import("@fusion/core");
|
||||
const backendBoot = await createTaskStoreForBackend({
|
||||
rootDir,
|
||||
/* FNXC:MigrationHoldingPage 2026-07-17-13:20: forward the SQLite→PG migration stream so the launch gate can show live progress instead of a silent multi-minute "Starting…". */
|
||||
onMigrationProgress: (event) =>
|
||||
onMigrationProgress?.({ active: true, phase: event.phase, label: formatMigrationProgress(event) }),
|
||||
});
|
||||
/* FNXC:PostgresDesktopRuntime 2026-07-14-18:34: Desktop startup must fail visibly if PostgreSQL cannot boot; the removed opt-out must never construct an unbacked SQLite TaskStore. */
|
||||
const store = backendBoot.taskStore as unknown as TaskStoreLike;
|
||||
// Attach the backend shutdown so LocalRuntimeManager can invoke it on stop.
|
||||
@@ -365,7 +390,7 @@ export class LocalRuntimeManager {
|
||||
private status: DesktopRuntimeStatus = { source: "none", state: "stopped" };
|
||||
|
||||
private readonly getExternalPort: () => number | undefined;
|
||||
private readonly createStore: (rootDir: string) => Promise<TaskStoreLike>;
|
||||
private readonly createStore: (rootDir: string, onMigrationProgress?: (progress: DesktopMigrationProgress) => void) => Promise<TaskStoreLike>;
|
||||
private readonly createDashboardServer: (store: TaskStoreLike, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
|
||||
private readonly startupRetries: number;
|
||||
private readonly startupRetryDelayMs: number;
|
||||
@@ -489,7 +514,18 @@ export class LocalRuntimeManager {
|
||||
|
||||
try {
|
||||
strace(`startEmbedded: BEGIN rootDir=${this.options.rootDir}`);
|
||||
store = await this.createStore(this.options.rootDir);
|
||||
/*
|
||||
FNXC:MigrationHoldingPage 2026-07-17-13:20:
|
||||
Publish live migration progress into the "starting" status so the renderer's
|
||||
DesktopLaunchGate (polling getRuntimeStatus every 250ms) can show it. Only
|
||||
overwrite while still starting — a late/stale event must never clobber a
|
||||
terminal running/error status.
|
||||
*/
|
||||
store = await this.createStore(this.options.rootDir, (progress) => {
|
||||
if (this.status.state === "starting") {
|
||||
this.status = { source: "embedded-local", state: "starting", migration: progress };
|
||||
}
|
||||
});
|
||||
strace("startEmbedded: store.init");
|
||||
await store.init();
|
||||
strace("startEmbedded: store.watch");
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface DesktopLocalServerState {
|
||||
status: "idle" | "starting" | "ready" | "error";
|
||||
port?: number;
|
||||
error?: string | null;
|
||||
/* FNXC:MigrationHoldingPage 2026-07-17-13:25: mirrors local-runtime.ts — live SQLite→PG migration progress while status is "starting". */
|
||||
migration?: { active: boolean; phase: string; label: string };
|
||||
}
|
||||
|
||||
export class DesktopLocalServerManager {
|
||||
@@ -67,7 +69,7 @@ export class DesktopLocalServerManager {
|
||||
let cleanup: RuntimeCleanup | undefined;
|
||||
|
||||
try {
|
||||
const { createTaskStoreForBackend } = await import("@fusion/core");
|
||||
const { createTaskStoreForBackend, formatMigrationProgress } = await import("@fusion/core");
|
||||
const { CentralCore, PluginLoader, ensureBundledPluginInstalled, isBundledPluginId } = await import("@fusion/core");
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine");
|
||||
@@ -75,7 +77,19 @@ export class DesktopLocalServerManager {
|
||||
// Consult the startup factory to boot a PostgreSQL-backed TaskStore.
|
||||
// Post default-flip: the factory boots embedded PG by default when
|
||||
// DATABASE_URL is unset and external PG when DATABASE_URL is set.
|
||||
const backendBoot = await createTaskStoreForBackend({ rootDir: this.rootDir });
|
||||
const backendBoot = await createTaskStoreForBackend({
|
||||
rootDir: this.rootDir,
|
||||
/* FNXC:MigrationHoldingPage 2026-07-17-13:25: keep this legacy path in sync with local-runtime.ts — publish live SQLite→PG migration progress while starting. */
|
||||
onMigrationProgress: (event) => {
|
||||
if (this.state.status === "starting") {
|
||||
this.state = {
|
||||
status: "starting",
|
||||
error: null,
|
||||
migration: { active: true, phase: event.phase, label: formatMigrationProgress(event) },
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
/* FNXC:PostgresDesktopRuntime 2026-07-14-18:34: The legacy local-server entrypoint shares the same mandatory PostgreSQL startup contract as the primary desktop runtime. */
|
||||
store = backendBoot.taskStore as unknown as TaskStoreLike;
|
||||
(store as TaskStoreLike & { __backendShutdown?: () => Promise<void> }).__backendShutdown =
|
||||
|
||||
@@ -1144,7 +1144,8 @@
|
||||
"backendError": {
|
||||
"failedFetch": "Failed to fetch projects"
|
||||
},
|
||||
"testMode": "Test mode — no real AI calls"
|
||||
"testMode": "Test mode — no real AI calls",
|
||||
"migrationInProgress": "Database migration in progress — the dashboard will reconnect when it completes."
|
||||
},
|
||||
"appName": "Fusion",
|
||||
"approval": {
|
||||
@@ -1994,6 +1995,7 @@
|
||||
"connectRemoteButton": "Connect to Remote Fusion",
|
||||
"couldNotStart": "Couldn't start local Fusion",
|
||||
"loading": "Loading Fusion…",
|
||||
"migrationInProgress": "Database migration in progress — this can take a few minutes.",
|
||||
"opening": "Opening…",
|
||||
"retry": "Retry",
|
||||
"runLocalButton": "Run Fusion Locally",
|
||||
|
||||
@@ -1134,7 +1134,8 @@
|
||||
"backendError": {
|
||||
"failedFetch": "Error al obtener proyectos"
|
||||
},
|
||||
"testMode": "Modo de prueba — sin llamadas reales de IA"
|
||||
"testMode": "Modo de prueba — sin llamadas reales de IA",
|
||||
"migrationInProgress": "Migración de base de datos en curso — el panel se reconectará cuando termine."
|
||||
},
|
||||
"appName": "",
|
||||
"approval": {
|
||||
@@ -1984,6 +1985,7 @@
|
||||
"connectRemoteButton": "Conectarse a Fusion remoto",
|
||||
"couldNotStart": "No se puede iniciar Fusion local",
|
||||
"loading": "Cargando Fusion…",
|
||||
"migrationInProgress": "Migración de base de datos en curso — puede tardar unos minutos.",
|
||||
"opening": "Abriendo…",
|
||||
"retry": "Reintentar",
|
||||
"runLocalButton": "Ejecutar Fusion localmente",
|
||||
|
||||
@@ -1134,7 +1134,8 @@
|
||||
"backendError": {
|
||||
"failedFetch": "Impossible de récupérer les projets"
|
||||
},
|
||||
"testMode": "Mode test — pas d'appels IA réels"
|
||||
"testMode": "Mode test — pas d'appels IA réels",
|
||||
"migrationInProgress": "Migration de la base de données en cours — le tableau de bord se reconnectera une fois terminée."
|
||||
},
|
||||
"appName": "",
|
||||
"approval": {
|
||||
@@ -1984,6 +1985,7 @@
|
||||
"connectRemoteButton": "Se connecter à Fusion distant",
|
||||
"couldNotStart": "Impossible de démarrer Fusion local",
|
||||
"loading": "Chargement de Fusion…",
|
||||
"migrationInProgress": "Migration de la base de données en cours — cela peut prendre quelques minutes.",
|
||||
"opening": "Ouverture…",
|
||||
"retry": "Réessayer",
|
||||
"runLocalButton": "Exécuter Fusion localement",
|
||||
|
||||
@@ -1134,7 +1134,8 @@
|
||||
"backendError": {
|
||||
"failedFetch": "프로젝트를 불러오지 못했습니다"
|
||||
},
|
||||
"testMode": "테스트 모드 — 실제 AI 호출 없음"
|
||||
"testMode": "테스트 모드 — 실제 AI 호출 없음",
|
||||
"migrationInProgress": "데이터베이스 마이그레이션 진행 중 — 완료되면 대시보드가 다시 연결됩니다."
|
||||
},
|
||||
"appName": "",
|
||||
"approval": {
|
||||
@@ -1984,6 +1985,7 @@
|
||||
"connectRemoteButton": "원격 Fusion에 연결",
|
||||
"couldNotStart": "로컬 Fusion을 시작할 수 없습니다",
|
||||
"loading": "Fusion 로드 중…",
|
||||
"migrationInProgress": "데이터베이스 마이그레이션 진행 중 — 몇 분 정도 걸릴 수 있습니다.",
|
||||
"opening": "열고 있는 중…",
|
||||
"retry": "다시 시도",
|
||||
"runLocalButton": "로컬에서 Fusion 실행",
|
||||
|
||||
@@ -1134,7 +1134,8 @@
|
||||
"backendError": {
|
||||
"failedFetch": "获取项目失败"
|
||||
},
|
||||
"testMode": "测试模式 — 无实际 AI 调用"
|
||||
"testMode": "测试模式 — 无实际 AI 调用",
|
||||
"migrationInProgress": "数据库迁移进行中 — 完成后仪表盘将自动重新连接。"
|
||||
},
|
||||
"appName": "",
|
||||
"approval": {
|
||||
@@ -1984,6 +1985,7 @@
|
||||
"connectRemoteButton": "连接到远程 Fusion",
|
||||
"couldNotStart": "无法启动本地 Fusion",
|
||||
"loading": "正在加载 Fusion…",
|
||||
"migrationInProgress": "数据库迁移进行中 — 可能需要几分钟。",
|
||||
"opening": "正在打开…",
|
||||
"retry": "重试",
|
||||
"runLocalButton": "本地运行 Fusion",
|
||||
|
||||
@@ -1134,7 +1134,8 @@
|
||||
"backendError": {
|
||||
"failedFetch": "取得項目失敗"
|
||||
},
|
||||
"testMode": "測試模式 — 無實際 AI 呼叫"
|
||||
"testMode": "測試模式 — 無實際 AI 呼叫",
|
||||
"migrationInProgress": "資料庫遷移進行中 — 完成後儀表板將自動重新連線。"
|
||||
},
|
||||
"appName": "",
|
||||
"approval": {
|
||||
@@ -1984,6 +1985,7 @@
|
||||
"connectRemoteButton": "連接到遠端 Fusion",
|
||||
"couldNotStart": "無法啟動本機 Fusion",
|
||||
"loading": "正在載入 Fusion…",
|
||||
"migrationInProgress": "資料庫遷移進行中 — 可能需要幾分鐘。",
|
||||
"opening": "正在開啟…",
|
||||
"retry": "重試",
|
||||
"runLocalButton": "本機執行 Fusion",
|
||||
|
||||
Reference in New Issue
Block a user