mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-09-10 15:13:44 +00:00
refactor(#14): Address PR feedback
This commit is contained in:
@@ -126,7 +126,7 @@ If you prefer compose:
|
|||||||
services:
|
services:
|
||||||
open-network-diagram:
|
open-network-diagram:
|
||||||
image: jcreek23/open-network-diagram:latest
|
image: jcreek23/open-network-diagram:latest
|
||||||
user: '${OND_UID:-1000}:${OND_GID:-1000}'
|
user: '${OND_UID:?Set OND_UID to your host user ID}:${OND_GID:?Set OND_GID to your host group ID}'
|
||||||
ports:
|
ports:
|
||||||
- '8080:3000'
|
- '8080:3000'
|
||||||
volumes:
|
volumes:
|
||||||
@@ -144,7 +144,7 @@ mkdir -p data
|
|||||||
OND_UID="$(id -u)" OND_GID="$(id -g)" docker compose up -d
|
OND_UID="$(id -u)" OND_GID="$(id -g)" docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
`OND_UID` and `OND_GID` are used only by Compose to select the container process identity; they are not application environment variables. The defaults suit common Linux installations. Docker Desktop users can remove the `user` line if their file-sharing configuration requires the image's built-in user.
|
`OND_UID` and `OND_GID` are required Compose interpolation values supplied by the command above; they are not application environment variables. Docker Desktop users can remove the `user` line if their file-sharing configuration requires the image's built-in user.
|
||||||
|
|
||||||
### Using a Docker Named Volume
|
### Using a Docker Named Volume
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: open-network-diagram:local
|
image: open-network-diagram:local
|
||||||
user: '${OND_UID:-1000}:${OND_GID:-1000}'
|
user: '${OND_UID:?Set OND_UID to your host user ID}:${OND_GID:?Set OND_GID to your host group ID}'
|
||||||
ports:
|
ports:
|
||||||
- '8080:3000'
|
- '8080:3000'
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -301,6 +301,9 @@
|
|||||||
let subnetEditorOpen = false;
|
let subnetEditorOpen = false;
|
||||||
let showRackManager = false;
|
let showRackManager = false;
|
||||||
|
|
||||||
|
$: totalEntities = networkData.machines.length + networkData.devices.length;
|
||||||
|
$: emptyWritableDataset = writable && totalEntities === 0;
|
||||||
|
|
||||||
// Writable empty datasets use a session-only dismissal so onboarding
|
// Writable empty datasets use a session-only dismissal so onboarding
|
||||||
// returns after reload until the user adds a real entity. Sample-data
|
// returns after reload until the user adds a real entity. Sample-data
|
||||||
// dismissal remains persisted for returning demo users.
|
// dismissal remains persisted for returning demo users.
|
||||||
@@ -310,7 +313,7 @@
|
|||||||
let emptyFirstRunDismissedThisSession = false;
|
let emptyFirstRunDismissedThisSession = false;
|
||||||
|
|
||||||
function dismissFirstRun() {
|
function dismissFirstRun() {
|
||||||
if (writable && totalEntities === 0) {
|
if (emptyWritableDataset) {
|
||||||
emptyFirstRunDismissedThisSession = true;
|
emptyFirstRunDismissedThisSession = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -328,7 +331,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: totalEntities = networkData.machines.length + networkData.devices.length;
|
|
||||||
$: allExampleNames =
|
$: allExampleNames =
|
||||||
totalEntities > 0 &&
|
totalEntities > 0 &&
|
||||||
[
|
[
|
||||||
@@ -338,7 +340,7 @@
|
|||||||
$: showFirstRun =
|
$: showFirstRun =
|
||||||
hasLoadedInitialData &&
|
hasLoadedInitialData &&
|
||||||
!isLoadingData &&
|
!isLoadingData &&
|
||||||
((writable && totalEntities === 0 && !emptyFirstRunDismissedThisSession) ||
|
((emptyWritableDataset && !emptyFirstRunDismissedThisSession) ||
|
||||||
(!firstRunDismissed && totalEntities <= 4 && allExampleNames));
|
(!firstRunDismissed && totalEntities <= 4 && allExampleNames));
|
||||||
let copiedIp: string | null = null;
|
let copiedIp: string | null = null;
|
||||||
let copiedIpTimer: ReturnType<typeof setTimeout> | null = null;
|
let copiedIpTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -704,10 +706,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveStaticFallbackNotice(reason: string | null): string {
|
function resolveStaticFallbackNotice(reason: string | null): string {
|
||||||
if (!reason) {
|
const normalizedReason = reason?.trim();
|
||||||
|
if (!normalizedReason) {
|
||||||
return 'Read-only: API unavailable; using bundled static data.';
|
return 'Read-only: API unavailable; using bundled static data.';
|
||||||
}
|
}
|
||||||
return `Read-only: ${reason} Using bundled static data.`;
|
const separator = /[.!?]$/.test(normalizedReason) ? ' ' : '. ';
|
||||||
|
return `Read-only: ${normalizedReason}${separator}Using bundled static data.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nodes are invisible hit targets (interaction, layout, edge anchoring);
|
// Nodes are invisible hit targets (interaction, layout, edge anchoring);
|
||||||
@@ -2527,7 +2531,7 @@
|
|||||||
isSampleData={allExampleNames}
|
isSampleData={allExampleNames}
|
||||||
{writable}
|
{writable}
|
||||||
hasMachines={networkData.machines.length > 0}
|
hasMachines={networkData.machines.length > 0}
|
||||||
storageReady={writable && totalEntities === 0}
|
storageReady={emptyWritableDataset}
|
||||||
on:addmachine={addMachine}
|
on:addmachine={addMachine}
|
||||||
on:connectport={openFirstMachineEditor}
|
on:connectport={openFirstMachineEditor}
|
||||||
on:openipam={() => (showIpamPanel = true)}
|
on:openipam={() => (showIpamPanel = true)}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import path from 'node:path';
|
|||||||
* @typedef {{ status: 'ready'; data: NetworkData; source: string; updatedAt: string } | { status: 'missing' | 'blank'; source: string; updatedAt: string | null }} NetworkFileReadState
|
* @typedef {{ status: 'ready'; data: NetworkData; source: string; updatedAt: string } | { status: 'missing' | 'blank'; source: string; updatedAt: string | null }} NetworkFileReadState
|
||||||
* @typedef {NetworkFileReadResult & { initialized: boolean }} NetworkFileBootstrapResult
|
* @typedef {NetworkFileReadResult & { initialized: boolean }} NetworkFileBootstrapResult
|
||||||
* @typedef {{ writable: boolean; reason: string | null }} WritableState
|
* @typedef {{ writable: boolean; reason: string | null }} WritableState
|
||||||
* @typedef {{ createBackup?: boolean }} WriteNetworkFileOptions
|
* @typedef {{ createBackup?: boolean; beforeCommit?: () => Promise<void> }} WriteNetworkFileOptions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
||||||
@@ -29,6 +29,12 @@ const EMPTY_NETWORK_DATA = Object.freeze({
|
|||||||
machines: Object.freeze([]),
|
machines: Object.freeze([]),
|
||||||
devices: Object.freeze([])
|
devices: Object.freeze([])
|
||||||
});
|
});
|
||||||
|
/** @type {Map<string, Promise<unknown>>} */
|
||||||
|
const fileWriteOperations = new Map();
|
||||||
|
/** @type {Map<string, Promise<NetworkFileBootstrapResult>>} */
|
||||||
|
const initializationFlights = new Map();
|
||||||
|
|
||||||
|
class InitializationSupersededError extends Error {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
@@ -193,21 +199,18 @@ export async function trimBackups(backupDirectory, baseName, keep = 5) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {NetworkData} data
|
* @param {NetworkData} data
|
||||||
|
* @param {string} source
|
||||||
* @param {WriteNetworkFileOptions} [options]
|
* @param {WriteNetworkFileOptions} [options]
|
||||||
* @returns {Promise<NetworkFileMetadata>}
|
* @returns {Promise<NetworkFileMetadata>}
|
||||||
*/
|
*/
|
||||||
export async function writeNetworkFile(data, options) {
|
async function writeNetworkFileUnlocked(data, source, options) {
|
||||||
if (!isWriteEnabled()) {
|
if (!isWriteEnabled()) {
|
||||||
throw new Error('Writes disabled by NETWORK_READ_ONLY=true.');
|
throw new Error('Writes disabled by NETWORK_READ_ONLY=true.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = resolveDataFilePath();
|
|
||||||
const backupDirectory = resolveBackupDirectory();
|
const backupDirectory = resolveBackupDirectory();
|
||||||
const directory = path.dirname(source);
|
const directory = path.dirname(source);
|
||||||
await mkdir(directory, { recursive: true });
|
await mkdir(directory, { recursive: true });
|
||||||
if (options?.createBackup ?? true) {
|
|
||||||
await createBackupIfPresent(source, backupDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`;
|
const temporaryPath = `${source}.tmp-${Date.now()}-${process.pid}`;
|
||||||
const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`;
|
const jsonPayload = `${JSON.stringify(data, null, '\t')}\n`;
|
||||||
@@ -219,10 +222,14 @@ export async function writeNetworkFile(data, options) {
|
|||||||
await fileHandle.close();
|
await fileHandle.close();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
await options?.beforeCommit?.();
|
||||||
|
if (options?.createBackup ?? true) {
|
||||||
|
await createBackupIfPresent(source, backupDirectory);
|
||||||
|
}
|
||||||
await rename(temporaryPath, source);
|
await rename(temporaryPath, source);
|
||||||
} catch (renameError) {
|
} catch (error) {
|
||||||
await unlink(temporaryPath).catch(() => undefined);
|
await unlink(temporaryPath).catch(() => undefined);
|
||||||
throw renameError;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await trimBackups(backupDirectory, path.basename(source), 5);
|
await trimBackups(backupDirectory, path.basename(source), 5);
|
||||||
@@ -234,6 +241,36 @@ export async function writeNetworkFile(data, options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {string} source
|
||||||
|
* @param {() => Promise<T>} operation
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
async function withFileWriteLock(source, operation) {
|
||||||
|
const previousOperation = fileWriteOperations.get(source) ?? Promise.resolve();
|
||||||
|
const currentOperation = previousOperation.catch(() => undefined).then(operation);
|
||||||
|
fileWriteOperations.set(source, currentOperation);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await currentOperation;
|
||||||
|
} finally {
|
||||||
|
if (fileWriteOperations.get(source) === currentOperation) {
|
||||||
|
fileWriteOperations.delete(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {NetworkData} data
|
||||||
|
* @param {WriteNetworkFileOptions} [options]
|
||||||
|
* @returns {Promise<NetworkFileMetadata>}
|
||||||
|
*/
|
||||||
|
export function writeNetworkFile(data, options) {
|
||||||
|
const source = resolveDataFilePath();
|
||||||
|
return withFileWriteLock(source, () => writeNetworkFileUnlocked(data, source, options));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {Promise<WritableState>}
|
* @returns {Promise<WritableState>}
|
||||||
*/
|
*/
|
||||||
@@ -302,7 +339,7 @@ export async function checkWritableState() {
|
|||||||
*
|
*
|
||||||
* @returns {Promise<NetworkFileBootstrapResult>}
|
* @returns {Promise<NetworkFileBootstrapResult>}
|
||||||
*/
|
*/
|
||||||
export async function readOrInitializeNetworkFile() {
|
async function initializeNetworkFile() {
|
||||||
const state = await readNetworkFileState();
|
const state = await readNetworkFileState();
|
||||||
if (state.status === 'ready') {
|
if (state.status === 'ready') {
|
||||||
return {
|
return {
|
||||||
@@ -323,7 +360,34 @@ export async function readOrInitializeNetworkFile() {
|
|||||||
machines: [...EMPTY_NETWORK_DATA.machines],
|
machines: [...EMPTY_NETWORK_DATA.machines],
|
||||||
devices: [...EMPTY_NETWORK_DATA.devices]
|
devices: [...EMPTY_NETWORK_DATA.devices]
|
||||||
};
|
};
|
||||||
const metadata = await writeNetworkFile(data, { createBackup: false });
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = await withFileWriteLock(state.source, () =>
|
||||||
|
writeNetworkFileUnlocked(data, state.source, {
|
||||||
|
createBackup: false,
|
||||||
|
beforeCommit: async () => {
|
||||||
|
const currentState = await readNetworkFileState();
|
||||||
|
if (currentState.status === 'ready') {
|
||||||
|
throw new InitializationSupersededError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof InitializationSupersededError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const currentState = await readNetworkFileState();
|
||||||
|
if (currentState.status !== 'ready') {
|
||||||
|
throw new Error(`Network data file "${currentState.source}" is ${currentState.status}.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: currentState.data,
|
||||||
|
source: currentState.source,
|
||||||
|
updatedAt: currentState.updatedAt,
|
||||||
|
initialized: false
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
source: metadata.source,
|
source: metadata.source,
|
||||||
@@ -331,3 +395,24 @@ export async function readOrInitializeNetworkFile() {
|
|||||||
initialized: true
|
initialized: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<NetworkFileBootstrapResult>}
|
||||||
|
*/
|
||||||
|
export function readOrInitializeNetworkFile() {
|
||||||
|
const source = resolveDataFilePath();
|
||||||
|
const existingFlight = initializationFlights.get(source);
|
||||||
|
if (existingFlight) {
|
||||||
|
return existingFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flight = initializeNetworkFile();
|
||||||
|
initializationFlights.set(source, flight);
|
||||||
|
const clearFlight = () => {
|
||||||
|
if (initializationFlights.get(source) === flight) {
|
||||||
|
initializationFlights.delete(source);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
flight.then(clearFlight, clearFlight);
|
||||||
|
return flight;
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,23 @@ test('does not rewrite an existing valid empty network', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not overwrite real data written while a missing file is being initialized', async () => {
|
||||||
|
await withPersistenceEnvironment(async ({ persistence, source }) => {
|
||||||
|
const realData = {
|
||||||
|
machines: [{ machineName: 'Router' }],
|
||||||
|
devices: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const bootstrap = persistence.readOrInitializeNetworkFile();
|
||||||
|
const write = persistence.writeNetworkFile(realData, { createBackup: false });
|
||||||
|
const [bootstrapResult] = await Promise.all([bootstrap, write]);
|
||||||
|
|
||||||
|
assert.equal(bootstrapResult.initialized, false);
|
||||||
|
assert.deepEqual(bootstrapResult.data, realData);
|
||||||
|
assert.deepEqual(JSON.parse(await readFile(source, 'utf8')), realData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('does not replace malformed or schema-invalid non-empty JSON', async () => {
|
test('does not replace malformed or schema-invalid non-empty JSON', async () => {
|
||||||
await withPersistenceEnvironment(async ({ persistence, source }) => {
|
await withPersistenceEnvironment(async ({ persistence, source }) => {
|
||||||
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
|
await persistence.writeNetworkFile({ machines: [], devices: [] }, { createBackup: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user