From 4a63373efad83a7080e3590d3785395c720f149d Mon Sep 17 00:00:00 2001 From: jeremy-london Date: Tue, 13 Jan 2026 14:23:01 -0700 Subject: [PATCH 1/2] feat: implements auto loading of graph explorer config.json --- .../src/node-server.ts | 25 ++ .../core/StateProvider/autoLoadBackup.test.ts | 323 ++++++++++++++++++ .../src/core/StateProvider/autoLoadBackup.ts | 74 ++++ .../src/core/StateProvider/storageAtoms.ts | 4 + packages/graph-explorer/src/utils/env.ts | 2 + 5 files changed, 428 insertions(+) create mode 100644 packages/graph-explorer/src/core/StateProvider/autoLoadBackup.test.ts create mode 100644 packages/graph-explorer/src/core/StateProvider/autoLoadBackup.ts diff --git a/packages/graph-explorer-proxy-server/src/node-server.ts b/packages/graph-explorer-proxy-server/src/node-server.ts index c9c84fc3c..77a34b65d 100644 --- a/packages/graph-explorer-proxy-server/src/node-server.ts +++ b/packages/graph-explorer-proxy-server/src/node-server.ts @@ -179,6 +179,31 @@ app.use( ), ); +// Serve the backup config file if it exists +const backupConfigPath = path.resolve( + defaultConnectionFolderPath, + "graph-explorer-config.json", +); +app.get("/graph-explorer-config.json", (_req, res) => { + // Check if file exists before attempting to send it + if (!fs.existsSync(backupConfigPath)) { + res.status(404).send("Backup config file not found"); + return; + } + // Send file and handle any errors gracefully + res.sendFile(backupConfigPath, err => { + if (err) { + // File was deleted or became inaccessible after the exists check + if (!res.headersSent) { + res.status(404).send("Backup config file not found"); + } + } + }); +}); +if (fs.existsSync(backupConfigPath)) { + proxyLogger.info("Serving backup config file from: %s", backupConfigPath); +} + // Host the Graph Explorer UI static files const staticFilesVirtualPath = "/explorer"; const staticFilesPath = path.join(clientRoot, "dist"); diff --git a/packages/graph-explorer/src/core/StateProvider/autoLoadBackup.test.ts b/packages/graph-explorer/src/core/StateProvider/autoLoadBackup.test.ts new file mode 100644 index 000000000..fb3922835 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/autoLoadBackup.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import localforage from "localforage"; +import { + createRandomRawConfiguration, + createRandomSchema, +} from "@/utils/testing"; +import { serializeData } from "./serializeData"; +import { toJsonFileData } from "@/utils/fileData"; + +// Mock env module before importing autoLoadBackup +vi.mock("@/utils/env", () => ({ + env: { + GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG: false, + }, +})); + +import { autoLoadBackupIfExists } from "./autoLoadBackup"; +import { env } from "@/utils/env"; + +describe("autoLoadBackupIfExists", () => { + beforeEach(async () => { + // Clear localforage before each test + await localforage.clear(); + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + // Reset env mock to default (force load disabled) + vi.mocked(env).GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG = false; + }); + + afterEach(() => { + // Reset env mock + vi.mocked(env).GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG = false; + }); + + it("should skip auto-load when configuration already exists", async () => { + // Set up existing configuration + const config = createRandomRawConfiguration(); + const configMap = new Map([[config.id, config]]); + await localforage.setItem("configuration", configMap); + + await autoLoadBackupIfExists(); + + // Verify fetch was never called + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should skip auto-load when configuration exists as plain object", async () => { + // IndexedDB serializes Maps as plain objects, so test that case + const config = createRandomRawConfiguration(); + const configObj = { [config.id]: config }; + await localforage.setItem("configuration", configObj); + + await autoLoadBackupIfExists(); + + // Verify fetch was never called + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should attempt auto-load when configuration map is empty", async () => { + // Empty Map should trigger auto-load (size is 0, so no existing config) + await localforage.setItem("configuration", new Map()); + + // Mock fetch to return 404 + const mockResponse = { + ok: false, + status: 404, + }; + vi.mocked(global.fetch).mockResolvedValue( + mockResponse as unknown as Response, + ); + + await autoLoadBackupIfExists(); + + // Verify fetch was called (empty config means no existing data, so should try to load) + expect(global.fetch).toHaveBeenCalledWith("/graph-explorer-config.json"); + }); + + it("should auto-load backup when IndexedDB is empty and file exists", async () => { + // Ensure IndexedDB is empty + await localforage.clear(); + + // Create backup data using the real localforage mock + const config = createRandomRawConfiguration(); + const schema = createRandomSchema(); + const configMap = new Map([[config.id, config]]); + const schemaMap = new Map([[config.id, schema]]); + + // Pre-populate localforage with backup data structure + // (simulating what would be in a backup file) + const backupData = { + backupSource: "Graph Explorer", + backupSourceVersion: "2.6.0", + backupVersion: "1.0", + backupTimestamp: new Date(), + data: { + configuration: configMap, + schema: schemaMap, + "active-configuration": config.id, + }, + }; + const serialized = serializeData(backupData); + const blob = toJsonFileData(serialized); + + // Mock fetch to return the backup file + const mockResponse = { + ok: true, + blob: vi.fn().mockResolvedValue(blob), + }; + vi.mocked(global.fetch).mockResolvedValue( + mockResponse as unknown as Response, + ); + + await autoLoadBackupIfExists(); + + // Verify fetch was called with correct URL + expect(global.fetch).toHaveBeenCalledWith("/graph-explorer-config.json"); + + // Verify backup was restored (restoreBackup should have populated localforage) + // Note: The actual restore happens via restoreBackup which uses the mocked localforage + // Since restoreBackup uses keys() which may not be fully implemented in the mock, + // we verify that fetch was called and the function completed without error + expect(mockResponse.blob).toHaveBeenCalled(); + }); + + it("should handle 404 response gracefully when file doesn't exist", async () => { + // Ensure IndexedDB is empty + await localforage.clear(); + + // Mock fetch to return 404 + const mockResponse = { + ok: false, + status: 404, + }; + vi.mocked(global.fetch).mockResolvedValue( + mockResponse as unknown as Response, + ); + + await autoLoadBackupIfExists(); + + // Verify fetch was called + expect(global.fetch).toHaveBeenCalledWith("/graph-explorer-config.json"); + + // Verify nothing was restored + const config = await localforage.getItem("configuration"); + expect(config).toBeNull(); + }); + + it("should handle fetch errors gracefully", async () => { + // Ensure IndexedDB is empty + await localforage.clear(); + + // Mock fetch to throw an error + vi.mocked(global.fetch).mockRejectedValue(new Error("Network error")); + + // Should not throw + await expect(autoLoadBackupIfExists()).resolves.not.toThrow(); + + // Verify nothing was restored + const config = await localforage.getItem("configuration"); + expect(config).toBeNull(); + }); + + it("should handle restore errors gracefully", async () => { + // Ensure IndexedDB is empty + await localforage.clear(); + + // Create invalid backup blob + const invalidBlob = new Blob(["invalid json"], { + type: "application/json", + }); + + // Mock fetch to return invalid backup file + const mockResponse = { + ok: true, + blob: vi.fn().mockResolvedValue(invalidBlob), + }; + vi.mocked(global.fetch).mockResolvedValue( + mockResponse as unknown as Response, + ); + + // Should not throw + await expect(autoLoadBackupIfExists()).resolves.not.toThrow(); + + // Verify nothing was restored + const config = await localforage.getItem("configuration"); + expect(config).toBeNull(); + }); + + it("should handle empty configuration object gracefully", async () => { + // Empty object should be treated as no config + await localforage.setItem("configuration", {}); + + await autoLoadBackupIfExists(); + + // Should attempt to fetch (empty object means no existing data) + expect(global.fetch).toHaveBeenCalledWith("/graph-explorer-config.json"); + }); + + it("should restore all backup data correctly", async () => { + // Ensure IndexedDB is empty + await localforage.clear(); + + // Create comprehensive backup data + const config1 = createRandomRawConfiguration(); + const config2 = createRandomRawConfiguration(); + const schema1 = createRandomSchema(); + const schema2 = createRandomSchema(); + const configMap = new Map([ + [config1.id, config1], + [config2.id, config2], + ]); + const schemaMap = new Map([ + [config1.id, schema1], + [config2.id, schema2], + ]); + + // Create backup data structure + const backupData = { + backupSource: "Graph Explorer", + backupSourceVersion: "2.6.0", + backupVersion: "1.0", + backupTimestamp: new Date(), + data: { + configuration: configMap, + schema: schemaMap, + "active-configuration": config1.id, + "user-styling": {}, + "user-layout": {}, + }, + }; + const serialized = serializeData(backupData); + const blob = toJsonFileData(serialized); + + // Mock fetch to return the backup file + const mockResponse = { + ok: true, + blob: vi.fn().mockResolvedValue(blob), + }; + vi.mocked(global.fetch).mockResolvedValue( + mockResponse as unknown as Response, + ); + + await autoLoadBackupIfExists(); + + // Verify fetch was called + expect(global.fetch).toHaveBeenCalledWith("/graph-explorer-config.json"); + expect(mockResponse.blob).toHaveBeenCalled(); + + // Note: Full restoration verification would require the mocked localforage + // to have a fully functional keys() method. The important thing is that + // the function completes without error and attempts to restore. + }); + + it("should force load backup when GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG is true", async () => { + // Set up existing configuration + const config = createRandomRawConfiguration(); + const configMap = new Map([[config.id, config]]); + await localforage.setItem("configuration", configMap); + + // Mock env to enable force load and reset modules to pick up the change + vi.mocked(env).GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG = true; + vi.resetModules(); + const { autoLoadBackupIfExists: autoLoadWithForce } = + await import("./autoLoadBackup"); + + // Create backup data + const newConfig = createRandomRawConfiguration(); + const backupData = { + backupSource: "Graph Explorer", + backupSourceVersion: "2.6.0", + backupVersion: "1.0", + backupTimestamp: new Date(), + data: { + configuration: new Map([["New Config", newConfig]]), + "active-configuration": "New Config", + }, + }; + const serialized = serializeData(backupData); + const blob = toJsonFileData(serialized); + + // Mock fetch to return the backup file + const mockResponse = { + ok: true, + blob: vi.fn().mockResolvedValue(blob), + }; + vi.mocked(global.fetch).mockResolvedValue( + mockResponse as unknown as Response, + ); + + await autoLoadWithForce(); + + // Verify fetch was called despite existing config + expect(global.fetch).toHaveBeenCalledWith("/graph-explorer-config.json"); + expect(mockResponse.blob).toHaveBeenCalled(); + }); + + it("should warn when force load is enabled but file doesn't exist", async () => { + // Set up existing configuration + const config = createRandomRawConfiguration(); + const configMap = new Map([[config.id, config]]); + await localforage.setItem("configuration", configMap); + + // Mock env to enable force load and reset modules to pick up the change + vi.mocked(env).GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG = true; + vi.resetModules(); + const { autoLoadBackupIfExists: autoLoadWithForce } = + await import("./autoLoadBackup"); + + // Mock fetch to return 404 + const mockResponse = { + ok: false, + status: 404, + }; + vi.mocked(global.fetch).mockResolvedValue( + mockResponse as unknown as Response, + ); + + await autoLoadWithForce(); + + // Verify fetch was called + expect(global.fetch).toHaveBeenCalledWith("/graph-explorer-config.json"); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/autoLoadBackup.ts b/packages/graph-explorer/src/core/StateProvider/autoLoadBackup.ts new file mode 100644 index 000000000..3970e688f --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/autoLoadBackup.ts @@ -0,0 +1,74 @@ +import localforage from "localforage"; +import { logger } from "@/utils"; +import { env } from "@/utils/env"; +import { readBackupDataFromFile, restoreBackup, type LocalDb } from "./localDb"; + +const BACKUP_CONFIG_URL = "/graph-explorer-config.json"; + +/** + * Auto-loads the backup configuration file if IndexedDB is empty. + * This function checks if any configuration exists in IndexedDB, and if not, + * attempts to fetch and restore the backup config file from the server. + * + * When GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG is set to "true", the IndexedDB + * check is bypassed and the backup config is always loaded, ensuring the + * mounted config file takes precedence. + * + * This is intended to be called before React initializes (in storageAtoms.ts) + * to ensure the backup is loaded before atoms are created. + * + * Uses the existing backup/restore functionality from localDb.ts to maintain + * consistency with the manual backup/restore feature. + */ +export async function autoLoadBackupIfExists() { + try { + // If force load is enabled, skip IndexedDB check and always load + const forceLoad = env.GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG; + + if (!forceLoad) { + // Check if we already have configuration data + // Note: IndexedDB serializes Maps as plain objects, so we check both cases + const existingConfigs = await localforage.getItem< + Map | Record + >("configuration"); + if (existingConfigs) { + const configSize = + existingConfigs instanceof Map + ? existingConfigs.size + : Object.keys(existingConfigs).length; + if (configSize > 0) { + logger.debug("Configuration already exists, skipping auto-load"); + return; + } + } + } else { + logger.debug("Force load enabled, bypassing IndexedDB check"); + } + + // Try to fetch the backup config file + const response = await fetch(BACKUP_CONFIG_URL); + if (!response.ok) { + if (forceLoad) { + logger.warn( + "Force load enabled but backup config file not found or not accessible", + ); + } else { + logger.debug("No backup config file found or not accessible"); + } + return; + } + + // Read and restore the backup + const blob = await response.blob(); + const backupData = await readBackupDataFromFile(blob); + await restoreBackup(backupData, localforage as LocalDb); + logger.log( + forceLoad + ? "Force-loaded backup configuration from file (overrode IndexedDB)" + : "Auto-loaded backup configuration from file", + ); + } catch (error) { + logger.warn("Failed to auto-load backup configuration", error); + // Don't throw - allow app to continue with default state + } +} diff --git a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts index 8bbe38ce6..82e505659 100644 --- a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts +++ b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts @@ -9,6 +9,7 @@ import { type UserStyling, } from "./index"; import { atomWithLocalForage } from "./atomWithLocalForage"; +import { autoLoadBackupIfExists } from "./autoLoadBackup"; /** DEV NOTE @@ -46,6 +47,9 @@ import { atomWithLocalForage } from "./atomWithLocalForage"; of the app on slower machines. */ +// Auto-load backup config before initializing atoms +await autoLoadBackupIfExists(); + const [ activeConfigurationAtom, configurationAtom, diff --git a/packages/graph-explorer/src/utils/env.ts b/packages/graph-explorer/src/utils/env.ts index 9fde691a8..8075d1683 100644 --- a/packages/graph-explorer/src/utils/env.ts +++ b/packages/graph-explorer/src/utils/env.ts @@ -4,4 +4,6 @@ export const env = { MODE: import.meta.env.MODE, BASE_URL: import.meta.env.BASE_URL, GRAPH_EXP_FEEDBACK_URL: import.meta.env.GRAPH_EXP_FEEDBACK_URL, + GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG: + import.meta.env.GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG === "true", }; From 29c7066f54efbcb75270b45ce4631457b0f976e5 Mon Sep 17 00:00:00 2001 From: jeremy-london Date: Tue, 13 Jan 2026 14:23:22 -0700 Subject: [PATCH 2/2] docs: updates for auto load of backup configuration --- Changelog.md | 22 +++++++++ README.md | 5 +++ additionaldocs/features/README.md | 5 ++- additionaldocs/getting-started/README.md | 5 +++ additionaldocs/troubleshooting.md | 57 +++++++++++++++++++++--- 5 files changed, 88 insertions(+), 6 deletions(-) diff --git a/Changelog.md b/Changelog.md index 0abcfc47d..86c9fd403 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,27 @@ # Graph Explorer Change Log +## Release 2.5.2 + +This release adds automatic loading of backup configuration files on startup, +enabling infrastructure-as-code deployments and simplifying Docker-based setups. + +### New Features + +- **Auto-Load Backup Configuration**: Graph Explorer now automatically loads + `graph-explorer-config.json` backup file on startup when IndexedDB is empty, + similar to how `defaultConnection.json` is auto-loaded. This enables + infrastructure-as-code deployments and simplifies Docker-based setups. An + optional `GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG` environment variable allows + forcing the backup config to always load, overriding IndexedDB data. + +### All Changes + +- Add automatic loading of backup configuration file on startup by @jeremy-london + in https://github.com/aws/graph-explorer/pull/1453 + +**Full Changelog**: +https://github.com/aws/graph-explorer/compare/v2.5.1...v2.5.2 + ## Release 2.5.1 This release includes a fix for a regression that caused neighbor expansion in diff --git a/README.md b/README.md index afadcef05..aab467a9e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,11 @@ defaults, and their descriptions. minutes). - `GRAPH_EXP_NODE_EXPANSION_LIMIT` - `None` - Controls the limit for node counts and expansion queries. + - `GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG` - `false` - When set to `"true"`, + always loads the backup configuration file (`graph-explorer-config.json`) on + startup, overriding any existing IndexedDB data. Useful for Docker + deployments where you want the mounted config file to always take + precedence. - Conditionally Required: - Required if `USING_PROXY_SERVER=True` - `GRAPH_CONNECTION_URL` - `None` - See diff --git a/additionaldocs/features/README.md b/additionaldocs/features/README.md index 1dc330ff4..03fab5d07 100644 --- a/additionaldocs/features/README.md +++ b/additionaldocs/features/README.md @@ -14,7 +14,10 @@ out our [roadmap](../../ROADMAP.md) and participate in the discussions. - **Save Configuration:** This action will export all the configuration data within the Graph Explorer local database. This will not store any data from the connected graph databases. However, the export may contain the shape of - the schema for your databases and the connection URL. + the schema for your databases and the connection URL. The exported file is + named `graph-explorer-config.json` and can be used for automatic loading on + startup (see + [Auto-Load Backup Configuration](../troubleshooting.md#auto-load-backup-configuration)). - **Load Configuration:** This action will replace all the Graph Explorer configuration data you currently have with the data in the provided configuration file. This is a destructive act and can not be undone. It is diff --git a/additionaldocs/getting-started/README.md b/additionaldocs/getting-started/README.md index 6b190767f..e8db71496 100644 --- a/additionaldocs/getting-started/README.md +++ b/additionaldocs/getting-started/README.md @@ -71,6 +71,11 @@ Docker image. You can find the latest version of the image on you should now see the Connections UI. See below description on Connections UI to configure your first connection to Amazon Neptune. + +> [!TIP] +> +> You can automatically load a backup configuration file (`graph-explorer-config.json`) on startup by mounting it as a volume. See [Auto-Load Backup Configuration](../troubleshooting.md#auto-load-backup-configuration) for details. + #### Gremlin Server Database Gremlin Server is an easy way to get started with graph databases. This example diff --git a/additionaldocs/troubleshooting.md b/additionaldocs/troubleshooting.md index fdd5f15f3..461ff5239 100644 --- a/additionaldocs/troubleshooting.md +++ b/additionaldocs/troubleshooting.md @@ -179,21 +179,68 @@ may receive 404 not found responses or get connection refused errors. ## Backup Graph Explorer Data Inside of Graph Explorer there is an option to export all the configuration data -that Graph Explorer uses. This data is local to the user’s browser and does not +that Graph Explorer uses. This data is local to the user's browser and does not exist on the server. To gather the config data: 1. Launch Graph Explorer 2. Navigate to the connections screen -3. Press the “Settings” button in the navigation bar -4. Select the “General” page within settings -5. Press the “Save Configuration” button +3. Press the "Settings" button in the navigation bar +4. Select the "General" page within settings +5. Press the "Save Configuration" button 6. Choose where to save the exported file -This backup can be restored using the “Load Configuration” button in the same +This backup can be restored using the "Load Configuration" button in the same settings page. +### Auto-Load Backup Configuration + +Graph Explorer can automatically load a backup configuration file +(`graph-explorer-config.json`) on startup, similar to how +`defaultConnection.json` is auto-loaded. This is useful for Docker deployments +and infrastructure-as-code scenarios. + +**How it works:** + +- The backup config file (`graph-explorer-config.json`) should be placed in the + `CONFIGURATION_FOLDER_PATH` directory (same location as + `defaultConnection.json`) +- On startup, Graph Explorer checks if IndexedDB is empty +- If empty, it automatically fetches and restores the backup config file +- This ensures connections, styles, schemas, and other customizations are loaded + automatically + +**Docker Setup:** + +```yaml +services: + graph-explorer: + volumes: + - ./config/graph-explorer:/graph-explorer-config + environment: + - CONFIGURATION_FOLDER_PATH=/graph-explorer-config +``` + +**Force Load Option:** + +By default, the backup config only loads when IndexedDB is empty (to prevent +overwriting existing user data). To always load the backup config file, +regardless of IndexedDB contents, set: + +```yaml +environment: + - GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG=true +``` + +**Behavior:** + +- `defaultConnection.json` → ✅ Auto-loads (connections only) - always works +- `graph-explorer-config.json` → ✅ Auto-loads (full config) when IndexedDB is + empty - default behavior +- `graph-explorer-config.json` → ✅ Always loads when + `GRAPH_EXP_FORCE_LOAD_BACKUP_CONFIG=true` - override behavior + ## Gathering SageMaker Logs The Graph Explorer proxy server outputs log statements to standard out. By