FN-7852: add one-time SQLite→embedded Postgres storage notice banner
Adds a dismissible, one-time dashboard banner announcing the upcoming SQLite→embedded-Postgres storage backend change. - New self-contained StorageMigrationNoticeBanner component with title/body copy and a dismiss control that persists via localStorage key fusion:storage-migration-notice-dismissed - Wire the banner into DashboardBanners alongside the CLI binary install banner for project-scoped views - Add en locale strings (storageMigrationNotice.title/body/dismissLabel) in app.json - Add component test coverage for render/dismiss/persistence behavior - Document the notice in docs/dashboard-guide.md - Add a minor changeset for @runfusion/fusion Files changed: .changeset/fn-7852-storage-migration-notice.md | 7 ++ docs/dashboard-guide.md | 2 + .../components/StorageMigrationNoticeBanner.css | 73 +++++++++++++++++++ .../components/StorageMigrationNoticeBanner.tsx | 65 +++++++++++++++++ .../StorageMigrationNoticeBanner.test.tsx | 82 ++++++++++++++++++++++ .../app/components/dashboard/DashboardBanners.tsx | 11 ++- packages/i18n/locales/en/app.json | 5 ++ 7 files changed, 242 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7852 Fusion-Task-Lineage: e3235cce-9ca3-4830-8733-a2ec46c53246 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7852-storage-migration-notice.md
Normal file
7
.changeset/fn-7852-storage-migration-notice.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Add a one-time banner announcing the upcoming SQLite→embedded-Postgres storage change.
|
||||||
|
category: feature
|
||||||
|
dev: New self-contained `StorageMigrationNoticeBanner` in the dashboard banner cluster; permanent dismissal via `localStorage` key `fusion:storage-migration-notice-dismissed`.
|
||||||
@@ -789,6 +789,8 @@ Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic AP
|
|||||||
|
|
||||||
## Setup Warning Banner
|
## Setup Warning Banner
|
||||||
|
|
||||||
|
The dashboard banner cluster can also show a one-time storage notice announcing that the next Fusion version replaces the current SQLite data store with an embedded Postgres backend; dismissing it stores a browser-local acknowledgement so it does not reappear.
|
||||||
|
|
||||||
The dashboard and New Task modal show setup warnings only after readiness checks finish. AI-provider warnings appear immediately because agents cannot work without a provider. GitHub warnings are delayed per project: Fusion records the first time GitHub OAuth and authenticated `gh` CLI are both missing, waits one day, and then shows **GitHub not connected** if GitHub is still unavailable. Reconnecting GitHub clears the timer so a later disconnect starts a fresh one-day grace period.
|
The dashboard and New Task modal show setup warnings only after readiness checks finish. AI-provider warnings appear immediately because agents cannot work without a provider. GitHub warnings are delayed per project: Fusion records the first time GitHub OAuth and authenticated `gh` CLI are both missing, waits one day, and then shows **GitHub not connected** if GitHub is still unavailable. Reconnecting GitHub clears the timer so a later disconnect starts a fresh one-day grace period.
|
||||||
|
|
||||||
When the dashboard GitHub warning is visible, its **Connect GitHub** action opens **Settings → Authentication**. The New Task modal keeps immediate AI-provider warnings but suppresses the GitHub warning because that modal does not own the Settings navigation callback required for an actionable GitHub setup control.
|
When the dashboard GitHub warning is visible, its **Connect GitHub** action opens **Settings → Authentication**. The New Task modal keeps immediate AI-provider warnings but suppresses the GitHub warning because that modal does not own the Settings navigation callback required for an actionable GitHub setup control.
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/* === StorageMigrationNoticeBanner === */
|
||||||
|
.storage-migration-notice-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin: var(--space-sm) var(--space-md) 0;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
color-mix(in srgb, var(--todo) 12%, transparent),
|
||||||
|
color-mix(in srgb, var(--todo) 4%, transparent)
|
||||||
|
);
|
||||||
|
border: var(--btn-border-width) solid color-mix(in srgb, var(--todo) 35%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-migration-notice-banner__body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-migration-notice-banner__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-migration-notice-banner__text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-migration-notice-banner__dismiss {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 0;
|
||||||
|
transition: color var(--transition-fast), background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-migration-notice-banner__dismiss:hover {
|
||||||
|
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-migration-notice-banner__dismiss:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.storage-migration-notice-banner {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-migration-notice-banner__dismiss {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import "./StorageMigrationNoticeBanner.css";
|
||||||
|
|
||||||
|
export const STORAGE_MIGRATION_NOTICE_DISMISS_KEY = "fusion:storage-migration-notice-dismissed";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:StorageMigrationNotice 2026-07-12-00:00:
|
||||||
|
The dashboard must announce the next-version SQLite → embedded Postgres storage change before operators upgrade. Dismissal is global rather than project-scoped because the notice describes an app-wide on-disk storage engine change, not a per-project condition; localStorage persistence makes the one-time acknowledgement permanent on this browser profile.
|
||||||
|
*/
|
||||||
|
function isDismissed(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(STORAGE_MIGRATION_NOTICE_DISMISS_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistDismissal(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_MIGRATION_NOTICE_DISMISS_KEY, "1");
|
||||||
|
} catch {
|
||||||
|
// Ignore quota / private-mode errors — the click still hides this in-memory render.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-time informational banner for the upcoming dashboard storage backend change. */
|
||||||
|
export function StorageMigrationNoticeBanner() {
|
||||||
|
const { t } = useTranslation("app");
|
||||||
|
const [dismissed, setDismissed] = useState<boolean>(() => isDismissed());
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
persistDismissal();
|
||||||
|
setDismissed(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (dismissed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="storage-migration-notice-banner" role="status" aria-live="polite">
|
||||||
|
<div className="storage-migration-notice-banner__body">
|
||||||
|
<div className="storage-migration-notice-banner__title">
|
||||||
|
{t("storageMigrationNotice.title", "Storage update coming in the next Fusion version")}
|
||||||
|
</div>
|
||||||
|
<p className="storage-migration-notice-banner__text">
|
||||||
|
{t(
|
||||||
|
"storageMigrationNotice.body",
|
||||||
|
"The next Fusion version will replace the current SQLite data store with an embedded Postgres backend for data storage. No migration runs from this notice; it is an advance heads-up for operators who rely on the current .fusion/fusion.db SQLite file.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="storage-migration-notice-banner__dismiss touch-target"
|
||||||
|
aria-label={t("storageMigrationNotice.dismissLabel", "Dismiss storage update notice")}
|
||||||
|
onClick={handleDismiss}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
STORAGE_MIGRATION_NOTICE_DISMISS_KEY,
|
||||||
|
StorageMigrationNoticeBanner,
|
||||||
|
} from "../StorageMigrationNoticeBanner";
|
||||||
|
|
||||||
|
const title = "Storage update coming in the next Fusion version";
|
||||||
|
const body = /next Fusion version will replace the current SQLite data store with an embedded Postgres backend/i;
|
||||||
|
const dismissLabel = "Dismiss storage update notice";
|
||||||
|
const originalLocalStorage = window.localStorage;
|
||||||
|
|
||||||
|
describe("StorageMigrationNoticeBanner", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalLocalStorage,
|
||||||
|
});
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalLocalStorage,
|
||||||
|
});
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the storage notice when the dismissal key is absent", () => {
|
||||||
|
render(<StorageMigrationNoticeBanner />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(title)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(body)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: dismissLabel })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides immediately and persists the dismissal key when dismissed", () => {
|
||||||
|
render(<StorageMigrationNoticeBanner />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: dismissLabel }));
|
||||||
|
|
||||||
|
expect(window.localStorage.getItem(STORAGE_MIGRATION_NOTICE_DISMISS_KEY)).toBe("1");
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: dismissLabel })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on a fresh mount when the dismissal key is already persisted", () => {
|
||||||
|
window.localStorage.setItem(STORAGE_MIGRATION_NOTICE_DISMISS_KEY, "1");
|
||||||
|
|
||||||
|
render(<StorageMigrationNoticeBanner />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(title)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: dismissLabel })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not crash when localStorage getItem or setItem throws", () => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("private mode getItem denied");
|
||||||
|
},
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("private mode setItem denied");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => render(<StorageMigrationNoticeBanner />)).not.toThrow();
|
||||||
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: dismissLabel }));
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { EngineStatusBanner } from "../EngineStatusBanner";
|
|||||||
import { OAuthReloginBanner } from "../OAuthReloginBanner";
|
import { OAuthReloginBanner } from "../OAuthReloginBanner";
|
||||||
import { SessionNotificationBanner } from "../SessionNotificationBanner";
|
import { SessionNotificationBanner } from "../SessionNotificationBanner";
|
||||||
import { CliBinaryInstallBanner } from "../CliBinaryInstallBanner";
|
import { CliBinaryInstallBanner } from "../CliBinaryInstallBanner";
|
||||||
|
import { StorageMigrationNoticeBanner } from "../StorageMigrationNoticeBanner";
|
||||||
import { OnboardingResumeCard } from "../OnboardingResumeCard";
|
import { OnboardingResumeCard } from "../OnboardingResumeCard";
|
||||||
import { PostOnboardingRecommendations } from "../PostOnboardingRecommendations";
|
import { PostOnboardingRecommendations } from "../PostOnboardingRecommendations";
|
||||||
import { UpdateAvailableBanner } from "../UpdateAvailableBanner";
|
import { UpdateAvailableBanner } from "../UpdateAvailableBanner";
|
||||||
@@ -101,9 +102,13 @@ export function DashboardBanners({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{viewMode === "project" && currentProject && (
|
{viewMode === "project" && currentProject && (
|
||||||
<CliBinaryInstallBanner
|
<>
|
||||||
onOpenSettings={() => openSettingsWithNav("general" as SectionId)}
|
{/* FNXC:StorageMigrationNotice 2026-07-12-00:00: Keep the one-time storage-backend announcement beside other project-scoped passive notices while its localStorage dismissal remains app-wide. */}
|
||||||
/>
|
<StorageMigrationNoticeBanner />
|
||||||
|
<CliBinaryInstallBanner
|
||||||
|
onOpenSettings={() => openSettingsWithNav("general" as SectionId)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{viewMode === "project" && currentProject && showOnboardingResumeCard && (
|
{viewMode === "project" && currentProject && showOnboardingResumeCard && (
|
||||||
<OnboardingResumeCard onResume={modalManager.openModelOnboarding} />
|
<OnboardingResumeCard onResume={modalManager.openModelOnboarding} />
|
||||||
|
|||||||
@@ -1420,6 +1420,11 @@
|
|||||||
"versionMismatchPrefix": "Your installed",
|
"versionMismatchPrefix": "Your installed",
|
||||||
"versionMismatchSuffix": "Update to stay in sync."
|
"versionMismatchSuffix": "Update to stay in sync."
|
||||||
},
|
},
|
||||||
|
"storageMigrationNotice": {
|
||||||
|
"body": "The next Fusion version will replace the current SQLite data store with an embedded Postgres backend for data storage. No migration runs from this notice; it is an advance heads-up for operators who rely on the current .fusion/fusion.db SQLite file.",
|
||||||
|
"dismissLabel": "Dismiss storage update notice",
|
||||||
|
"title": "Storage update coming in the next Fusion version"
|
||||||
|
},
|
||||||
"cliBinary": {
|
"cliBinary": {
|
||||||
"binaryLabel": "Binary:",
|
"binaryLabel": "Binary:",
|
||||||
"checking": "Checking…",
|
"checking": "Checking…",
|
||||||
|
|||||||
Reference in New Issue
Block a user