mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-13 02:53:45 +00:00
Merge pull request #15 from jcreek/14-read-only-mode
fix(#14): surface explicit writable diagnostics in API and read-only …
This commit is contained in:
@@ -128,6 +128,7 @@ pnpm run icons:manifest # regenerate local vendor icon manifest
|
|||||||
|
|
||||||
- API endpoint: `GET/PUT /api/network-data`
|
- API endpoint: `GET/PUT /api/network-data`
|
||||||
- Writes are enabled unless `NETWORK_READ_ONLY=true`
|
- Writes are enabled unless `NETWORK_READ_ONLY=true`
|
||||||
|
- When writes are unavailable, API responses include `writableReason` for diagnostics.
|
||||||
- Writes are persisted atomically to the configured data file
|
- Writes are persisted atomically to the configured data file
|
||||||
- Rolling backups are kept in the backup directory (last 5)
|
- Rolling backups are kept in the backup directory (last 5)
|
||||||
|
|
||||||
|
|||||||
+19
-9
@@ -4,8 +4,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs';
|
import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs';
|
||||||
import {
|
import {
|
||||||
checkWritableState,
|
getWritableState,
|
||||||
isWriteEnabled,
|
|
||||||
readNetworkFile,
|
readNetworkFile,
|
||||||
writeNetworkFile
|
writeNetworkFile
|
||||||
} from './src/lib/shared/networkPersistenceCore.mjs';
|
} from './src/lib/shared/networkPersistenceCore.mjs';
|
||||||
@@ -50,10 +49,17 @@ function badRequest(response, message, details) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReadOnlyErrorMessage(reason) {
|
||||||
|
if (!reason) {
|
||||||
|
return 'Write API unavailable in current deployment.';
|
||||||
|
}
|
||||||
|
return `Write API unavailable: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function serveApi(request, response) {
|
async function serveApi(request, response) {
|
||||||
if (request.method === 'GET') {
|
if (request.method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const payload = await readNetworkFile();
|
const [payload, writableState] = await Promise.all([readNetworkFile(), getWritableState()]);
|
||||||
const validation = validateNetworkData(payload.data);
|
const validation = validateNetworkData(payload.data);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
sendJson(response, 500, {
|
sendJson(response, 500, {
|
||||||
@@ -63,11 +69,10 @@ async function serveApi(request, response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canWrite = await checkWritableState();
|
|
||||||
|
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
writable: canWrite,
|
writable: writableState.writable,
|
||||||
|
writableReason: writableState.reason,
|
||||||
source: payload.source,
|
source: payload.source,
|
||||||
updatedAt: payload.updatedAt
|
updatedAt: payload.updatedAt
|
||||||
});
|
});
|
||||||
@@ -80,9 +85,12 @@ async function serveApi(request, response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === 'PUT') {
|
if (request.method === 'PUT') {
|
||||||
if (!isWriteEnabled()) {
|
const writableStateBeforeWrite = await getWritableState();
|
||||||
|
if (!writableStateBeforeWrite.writable) {
|
||||||
sendJson(response, 403, {
|
sendJson(response, 403, {
|
||||||
error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.'
|
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||||
|
writable: false,
|
||||||
|
writableReason: writableStateBeforeWrite.reason
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,9 +131,11 @@ async function serveApi(request, response) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const metadata = await writeNetworkFile(validation.data);
|
const metadata = await writeNetworkFile(validation.data);
|
||||||
|
const writableStateAfterWrite = await getWritableState();
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
writable: true,
|
writable: writableStateAfterWrite.writable,
|
||||||
|
writableReason: writableStateAfterWrite.reason,
|
||||||
source: metadata.source,
|
source: metadata.source,
|
||||||
updatedAt: metadata.updatedAt
|
updatedAt: metadata.updatedAt
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,7 +73,8 @@
|
|||||||
let saveState: SaveState = 'saved';
|
let saveState: SaveState = 'saved';
|
||||||
let dataSourceLabel = jsonPath;
|
let dataSourceLabel = jsonPath;
|
||||||
let writable = false;
|
let writable = false;
|
||||||
let readOnlyNotice = 'Read-only deployment; changes are not persisted.';
|
const defaultReadOnlyNotice = 'Read-only deployment; changes are not persisted.';
|
||||||
|
let readOnlyNotice = defaultReadOnlyNotice;
|
||||||
|
|
||||||
let showEthernetLabels = false;
|
let showEthernetLabels = false;
|
||||||
let diagramViewMode: DiagramViewMode = 'network';
|
let diagramViewMode: DiagramViewMode = 'network';
|
||||||
@@ -401,6 +402,13 @@
|
|||||||
return 'Unexpected error';
|
return 'Unexpected error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReadOnlyNotice(reason: string | null | undefined): string {
|
||||||
|
if (!reason) {
|
||||||
|
return defaultReadOnlyNotice;
|
||||||
|
}
|
||||||
|
return `Read-only: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
function createGraphStyles(theme: ThemeMode): cytoscape.Stylesheet[] {
|
function createGraphStyles(theme: ThemeMode): cytoscape.Stylesheet[] {
|
||||||
const nodeText = theme === 'dark' ? '#e2e8f0' : '#1e293b';
|
const nodeText = theme === 'dark' ? '#e2e8f0' : '#1e293b';
|
||||||
const textOutline = theme === 'dark' ? '#0f172a' : '#f8fafc';
|
const textOutline = theme === 'dark' ? '#0f172a' : '#f8fafc';
|
||||||
@@ -976,8 +984,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
|
const body = (await response.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
|
writableReason?: string | null;
|
||||||
|
};
|
||||||
writable = false;
|
writable = false;
|
||||||
readOnlyNotice = 'Read-only deployment; changes are not persisted.';
|
readOnlyNotice = resolveReadOnlyNotice(body.writableReason ?? body.error ?? null);
|
||||||
saveState = 'unsaved';
|
saveState = 'unsaved';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -998,10 +1010,14 @@
|
|||||||
const body = (await response.json()) as {
|
const body = (await response.json()) as {
|
||||||
data: NetworkData;
|
data: NetworkData;
|
||||||
writable: boolean;
|
writable: boolean;
|
||||||
|
writableReason?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
writable = body.writable;
|
writable = body.writable;
|
||||||
|
readOnlyNotice = body.writable
|
||||||
|
? defaultReadOnlyNotice
|
||||||
|
: resolveReadOnlyNotice(body.writableReason);
|
||||||
dataSourceLabel = body.source;
|
dataSourceLabel = body.source;
|
||||||
lastSavedSnapshot = JSON.stringify(body.data);
|
lastSavedSnapshot = JSON.stringify(body.data);
|
||||||
networkData = cloneNetworkData(body.data);
|
networkData = cloneNetworkData(body.data);
|
||||||
@@ -1385,6 +1401,7 @@
|
|||||||
const body = (await response.json()) as {
|
const body = (await response.json()) as {
|
||||||
data: NetworkData;
|
data: NetworkData;
|
||||||
writable: boolean;
|
writable: boolean;
|
||||||
|
writableReason?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -1396,6 +1413,9 @@
|
|||||||
networkData = cloneNetworkData(validation.data);
|
networkData = cloneNetworkData(validation.data);
|
||||||
ensureSelectedTargetValid();
|
ensureSelectedTargetValid();
|
||||||
writable = body.writable;
|
writable = body.writable;
|
||||||
|
readOnlyNotice = body.writable
|
||||||
|
? defaultReadOnlyNotice
|
||||||
|
: resolveReadOnlyNotice(body.writableReason);
|
||||||
dataSourceLabel = body.source;
|
dataSourceLabel = body.source;
|
||||||
lastSavedSnapshot = JSON.stringify(networkData);
|
lastSavedSnapshot = JSON.stringify(networkData);
|
||||||
saveState = 'saved';
|
saveState = 'saved';
|
||||||
@@ -1414,6 +1434,7 @@
|
|||||||
networkData = cloneNetworkData(fallbackData);
|
networkData = cloneNetworkData(fallbackData);
|
||||||
ensureSelectedTargetValid();
|
ensureSelectedTargetValid();
|
||||||
writable = false;
|
writable = false;
|
||||||
|
readOnlyNotice = 'Read-only: API unavailable; using bundled static data.';
|
||||||
dataSourceLabel = jsonPath;
|
dataSourceLabel = jsonPath;
|
||||||
lastSavedSnapshot = JSON.stringify(networkData);
|
lastSavedSnapshot = JSON.stringify(networkData);
|
||||||
saveState = 'saved';
|
saveState = 'saved';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
checkWritableState as checkWritableStateCore,
|
checkWritableState as checkWritableStateCore,
|
||||||
|
getWritableState as getWritableStateCore,
|
||||||
isWriteEnabled as isWriteEnabledCore,
|
isWriteEnabled as isWriteEnabledCore,
|
||||||
readNetworkFile as readNetworkFileCore,
|
readNetworkFile as readNetworkFileCore,
|
||||||
writeNetworkFile as writeNetworkFileCore
|
writeNetworkFile as writeNetworkFileCore
|
||||||
@@ -12,6 +13,11 @@ export interface NetworkFileMetadata {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WritableState {
|
||||||
|
writable: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function isWriteEnabled(): boolean {
|
export function isWriteEnabled(): boolean {
|
||||||
return isWriteEnabledCore();
|
return isWriteEnabledCore();
|
||||||
}
|
}
|
||||||
@@ -24,6 +30,10 @@ export async function writeNetworkFile(data: NetworkData): Promise<NetworkFileMe
|
|||||||
return (await writeNetworkFileCore(data)) as NetworkFileMetadata;
|
return (await writeNetworkFileCore(data)) as NetworkFileMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWritableState(): Promise<WritableState> {
|
||||||
|
return (await getWritableStateCore()) as WritableState;
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkWritableState(): Promise<boolean> {
|
export async function checkWritableState(): Promise<boolean> {
|
||||||
return checkWritableStateCore();
|
return checkWritableStateCore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import path from 'node:path';
|
|||||||
* @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData
|
* @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData
|
||||||
* @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata
|
* @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata
|
||||||
* @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult
|
* @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult
|
||||||
|
* @typedef {{ writable: boolean; reason: string | null }} WritableState
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true';
|
||||||
@@ -43,6 +44,38 @@ export function isWriteEnabled() {
|
|||||||
return !NETWORK_READ_ONLY;
|
return !NETWORK_READ_ONLY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} error
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatFileSystemErrorCode(error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
|
||||||
|
return error.code;
|
||||||
|
}
|
||||||
|
return 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} directory
|
||||||
|
* @param {string} label
|
||||||
|
* @returns {Promise<string | null>}
|
||||||
|
*/
|
||||||
|
async function assertDirectoryWritable(directory, label) {
|
||||||
|
try {
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
return `${label} directory "${directory}" could not be created (${formatFileSystemErrorCode(error)}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(directory, constants.W_OK);
|
||||||
|
} catch (error) {
|
||||||
|
return `${label} directory "${directory}" is not writable (${formatFileSystemErrorCode(error)}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} dataFilePath
|
* @param {string} dataFilePath
|
||||||
* @returns {Promise<string>}
|
* @returns {Promise<string>}
|
||||||
@@ -151,20 +184,62 @@ export async function writeNetworkFile(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<WritableState>}
|
||||||
*/
|
*/
|
||||||
export async function checkWritableState() {
|
export async function getWritableState() {
|
||||||
if (!isWriteEnabled()) {
|
if (!isWriteEnabled()) {
|
||||||
return false;
|
return {
|
||||||
|
writable: false,
|
||||||
|
reason: 'Writes disabled by NETWORK_READ_ONLY=true.'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = resolveDataFilePath();
|
const source = resolveDataFilePath();
|
||||||
const directory = path.dirname(source);
|
const directory = path.dirname(source);
|
||||||
try {
|
const dataDirectoryError = await assertDirectoryWritable(directory, 'Data');
|
||||||
await mkdir(directory, { recursive: true });
|
if (dataDirectoryError) {
|
||||||
await access(directory, constants.W_OK);
|
return {
|
||||||
} catch {
|
writable: false,
|
||||||
return false;
|
reason: dataDirectoryError
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
const backupDirectory = resolveBackupDirectory();
|
||||||
|
const backupDirectoryError = await assertDirectoryWritable(backupDirectory, 'Backup');
|
||||||
|
if (backupDirectoryError) {
|
||||||
|
return {
|
||||||
|
writable: false,
|
||||||
|
reason: backupDirectoryError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(source, constants.F_OK);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
writable: true,
|
||||||
|
reason: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(source, constants.R_OK | constants.W_OK);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
writable: false,
|
||||||
|
reason: `Data file "${source}" exists but is not readable and writable (${formatFileSystemErrorCode(error)}).`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
writable: true,
|
||||||
|
reason: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function checkWritableState() {
|
||||||
|
return (await getWritableState()).writable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { json } from '@sveltejs/kit';
|
|||||||
|
|
||||||
import { validateNetworkData } from '$lib/data/networkSchema';
|
import { validateNetworkData } from '$lib/data/networkSchema';
|
||||||
import {
|
import {
|
||||||
checkWritableState,
|
getWritableState,
|
||||||
isWriteEnabled,
|
|
||||||
readNetworkFile,
|
readNetworkFile,
|
||||||
writeNetworkFile
|
writeNetworkFile
|
||||||
} from '$lib/server/networkPersistence';
|
} from '$lib/server/networkPersistence';
|
||||||
@@ -16,11 +15,18 @@ function serializeValidationErrors(errors: Array<{ path: string; message: string
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReadOnlyErrorMessage(reason: string | null): string {
|
||||||
|
if (!reason) {
|
||||||
|
return 'Write API unavailable in current deployment.';
|
||||||
|
}
|
||||||
|
return `Write API unavailable: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
export const GET: RequestHandler = async () => {
|
||||||
try {
|
try {
|
||||||
const [{ data, source, updatedAt }, writable] = await Promise.all([
|
const [{ data, source, updatedAt }, writableState] = await Promise.all([
|
||||||
readNetworkFile(),
|
readNetworkFile(),
|
||||||
checkWritableState()
|
getWritableState()
|
||||||
]);
|
]);
|
||||||
const validation = validateNetworkData(data);
|
const validation = validateNetworkData(data);
|
||||||
if (!validation.valid || !validation.data) {
|
if (!validation.valid || !validation.data) {
|
||||||
@@ -35,7 +41,8 @@ export const GET: RequestHandler = async () => {
|
|||||||
|
|
||||||
return json({
|
return json({
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
writable,
|
writable: writableState.writable,
|
||||||
|
writableReason: writableState.reason,
|
||||||
source,
|
source,
|
||||||
updatedAt
|
updatedAt
|
||||||
});
|
});
|
||||||
@@ -47,10 +54,13 @@ export const GET: RequestHandler = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PUT: RequestHandler = async ({ request }) => {
|
export const PUT: RequestHandler = async ({ request }) => {
|
||||||
if (!isWriteEnabled()) {
|
const writableStateBeforeWrite = await getWritableState();
|
||||||
|
if (!writableStateBeforeWrite.writable) {
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.'
|
error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason),
|
||||||
|
writable: false,
|
||||||
|
writableReason: writableStateBeforeWrite.reason
|
||||||
},
|
},
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
@@ -76,10 +86,11 @@ export const PUT: RequestHandler = async ({ request }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const metadata = await writeNetworkFile(validation.data);
|
const metadata = await writeNetworkFile(validation.data);
|
||||||
const writable = await checkWritableState();
|
const writableStateAfterWrite = await getWritableState();
|
||||||
return json({
|
return json({
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
writable,
|
writable: writableStateAfterWrite.writable,
|
||||||
|
writableReason: writableStateAfterWrite.reason,
|
||||||
source: metadata.source,
|
source: metadata.source,
|
||||||
updatedAt: metadata.updatedAt
|
updatedAt: metadata.updatedAt
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user