diff --git a/README.md b/README.md index 422ebc9..6441a02 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ pnpm run icons:manifest # regenerate local vendor icon manifest - API endpoint: `GET/PUT /api/network-data` - 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 - Rolling backups are kept in the backup directory (last 5) diff --git a/server.mjs b/server.mjs index 512b4fe..81cac72 100644 --- a/server.mjs +++ b/server.mjs @@ -4,8 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { validateNetworkData } from './src/lib/shared/networkSchemaCore.mjs'; import { - checkWritableState, - isWriteEnabled, + getWritableState, readNetworkFile, writeNetworkFile } 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) { if (request.method === 'GET') { try { - const payload = await readNetworkFile(); + const [payload, writableState] = await Promise.all([readNetworkFile(), getWritableState()]); const validation = validateNetworkData(payload.data); if (!validation.valid) { sendJson(response, 500, { @@ -63,11 +69,10 @@ async function serveApi(request, response) { return; } - const canWrite = await checkWritableState(); - sendJson(response, 200, { data: validation.data, - writable: canWrite, + writable: writableState.writable, + writableReason: writableState.reason, source: payload.source, updatedAt: payload.updatedAt }); @@ -80,9 +85,12 @@ async function serveApi(request, response) { } if (request.method === 'PUT') { - if (!isWriteEnabled()) { + const writableStateBeforeWrite = await getWritableState(); + if (!writableStateBeforeWrite.writable) { sendJson(response, 403, { - error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.' + error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason), + writable: false, + writableReason: writableStateBeforeWrite.reason }); return; } @@ -123,9 +131,11 @@ async function serveApi(request, response) { try { const metadata = await writeNetworkFile(validation.data); + const writableStateAfterWrite = await getWritableState(); sendJson(response, 200, { data: validation.data, - writable: true, + writable: writableStateAfterWrite.writable, + writableReason: writableStateAfterWrite.reason, source: metadata.source, updatedAt: metadata.updatedAt }); diff --git a/src/lib/components/NetworkDiagram.svelte b/src/lib/components/NetworkDiagram.svelte index 6c863b3..b8ec12d 100644 --- a/src/lib/components/NetworkDiagram.svelte +++ b/src/lib/components/NetworkDiagram.svelte @@ -73,7 +73,8 @@ let saveState: SaveState = 'saved'; let dataSourceLabel = jsonPath; 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 diagramViewMode: DiagramViewMode = 'network'; @@ -401,6 +402,13 @@ return 'Unexpected error'; } + function resolveReadOnlyNotice(reason: string | null | undefined): string { + if (!reason) { + return defaultReadOnlyNotice; + } + return `Read-only: ${reason}`; + } + function createGraphStyles(theme: ThemeMode): cytoscape.Stylesheet[] { const nodeText = theme === 'dark' ? '#e2e8f0' : '#1e293b'; const textOutline = theme === 'dark' ? '#0f172a' : '#f8fafc'; @@ -976,8 +984,12 @@ }); if (response.status === 403) { + const body = (await response.json().catch(() => ({}))) as { + error?: string; + writableReason?: string | null; + }; writable = false; - readOnlyNotice = 'Read-only deployment; changes are not persisted.'; + readOnlyNotice = resolveReadOnlyNotice(body.writableReason ?? body.error ?? null); saveState = 'unsaved'; return; } @@ -998,10 +1010,14 @@ const body = (await response.json()) as { data: NetworkData; writable: boolean; + writableReason?: string | null; source: string; updatedAt: string; }; writable = body.writable; + readOnlyNotice = body.writable + ? defaultReadOnlyNotice + : resolveReadOnlyNotice(body.writableReason); dataSourceLabel = body.source; lastSavedSnapshot = JSON.stringify(body.data); networkData = cloneNetworkData(body.data); @@ -1385,6 +1401,7 @@ const body = (await response.json()) as { data: NetworkData; writable: boolean; + writableReason?: string | null; source: string; updatedAt: string; }; @@ -1396,6 +1413,9 @@ networkData = cloneNetworkData(validation.data); ensureSelectedTargetValid(); writable = body.writable; + readOnlyNotice = body.writable + ? defaultReadOnlyNotice + : resolveReadOnlyNotice(body.writableReason); dataSourceLabel = body.source; lastSavedSnapshot = JSON.stringify(networkData); saveState = 'saved'; @@ -1414,6 +1434,7 @@ networkData = cloneNetworkData(fallbackData); ensureSelectedTargetValid(); writable = false; + readOnlyNotice = 'Read-only: API unavailable; using bundled static data.'; dataSourceLabel = jsonPath; lastSavedSnapshot = JSON.stringify(networkData); saveState = 'saved'; diff --git a/src/lib/server/networkPersistence.ts b/src/lib/server/networkPersistence.ts index 455bae1..66e8164 100644 --- a/src/lib/server/networkPersistence.ts +++ b/src/lib/server/networkPersistence.ts @@ -1,5 +1,6 @@ import { checkWritableState as checkWritableStateCore, + getWritableState as getWritableStateCore, isWriteEnabled as isWriteEnabledCore, readNetworkFile as readNetworkFileCore, writeNetworkFile as writeNetworkFileCore @@ -12,6 +13,11 @@ export interface NetworkFileMetadata { updatedAt: string; } +export interface WritableState { + writable: boolean; + reason: string | null; +} + export function isWriteEnabled(): boolean { return isWriteEnabledCore(); } @@ -24,6 +30,10 @@ export async function writeNetworkFile(data: NetworkData): Promise { + return (await getWritableStateCore()) as WritableState; +} + export async function checkWritableState(): Promise { return checkWritableStateCore(); } diff --git a/src/lib/shared/networkPersistenceCore.mjs b/src/lib/shared/networkPersistenceCore.mjs index 55a6c85..29a2b03 100644 --- a/src/lib/shared/networkPersistenceCore.mjs +++ b/src/lib/shared/networkPersistenceCore.mjs @@ -16,6 +16,7 @@ import path from 'node:path'; * @typedef {{ machines: unknown[]; devices: unknown[] }} NetworkData * @typedef {{ source: string; updatedAt: string }} NetworkFileMetadata * @typedef {{ data: NetworkData } & NetworkFileMetadata} NetworkFileReadResult + * @typedef {{ writable: boolean; reason: string | null }} WritableState */ const NETWORK_READ_ONLY = process.env.NETWORK_READ_ONLY === 'true'; @@ -43,6 +44,38 @@ export function isWriteEnabled() { 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} + */ +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 * @returns {Promise} @@ -151,20 +184,62 @@ export async function writeNetworkFile(data) { } /** - * @returns {Promise} + * @returns {Promise} */ -export async function checkWritableState() { +export async function getWritableState() { if (!isWriteEnabled()) { - return false; + return { + writable: false, + reason: 'Writes disabled by NETWORK_READ_ONLY=true.' + }; } const source = resolveDataFilePath(); const directory = path.dirname(source); - try { - await mkdir(directory, { recursive: true }); - await access(directory, constants.W_OK); - } catch { - return false; + const dataDirectoryError = await assertDirectoryWritable(directory, 'Data'); + if (dataDirectoryError) { + return { + writable: 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} + */ +export async function checkWritableState() { + return (await getWritableState()).writable; } diff --git a/src/routes/api/network-data/+server.ts b/src/routes/api/network-data/+server.ts index 8059aec..2a487dc 100644 --- a/src/routes/api/network-data/+server.ts +++ b/src/routes/api/network-data/+server.ts @@ -2,8 +2,7 @@ import { json } from '@sveltejs/kit'; import { validateNetworkData } from '$lib/data/networkSchema'; import { - checkWritableState, - isWriteEnabled, + getWritableState, readNetworkFile, writeNetworkFile } 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 () => { try { - const [{ data, source, updatedAt }, writable] = await Promise.all([ + const [{ data, source, updatedAt }, writableState] = await Promise.all([ readNetworkFile(), - checkWritableState() + getWritableState() ]); const validation = validateNetworkData(data); if (!validation.valid || !validation.data) { @@ -35,7 +41,8 @@ export const GET: RequestHandler = async () => { return json({ data: validation.data, - writable, + writable: writableState.writable, + writableReason: writableState.reason, source, updatedAt }); @@ -47,10 +54,13 @@ export const GET: RequestHandler = async () => { }; export const PUT: RequestHandler = async ({ request }) => { - if (!isWriteEnabled()) { + const writableStateBeforeWrite = await getWritableState(); + if (!writableStateBeforeWrite.writable) { return json( { - error: 'Write API disabled. Unset NETWORK_READ_ONLY to enable persistence.' + error: resolveReadOnlyErrorMessage(writableStateBeforeWrite.reason), + writable: false, + writableReason: writableStateBeforeWrite.reason }, { status: 403 } ); @@ -76,10 +86,11 @@ export const PUT: RequestHandler = async ({ request }) => { try { const metadata = await writeNetworkFile(validation.data); - const writable = await checkWritableState(); + const writableStateAfterWrite = await getWritableState(); return json({ data: validation.data, - writable, + writable: writableStateAfterWrite.writable, + writableReason: writableStateAfterWrite.reason, source: metadata.source, updatedAt: metadata.updatedAt });