18 Commits

Author SHA1 Message Date
Josh Creek 72fd77b9c9 Merge pull request #19 from jcreek/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-07-12 15:54:09 +01:00
dependabot[bot] b04bc0acc9 build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-24 13:33:35 +00:00
Josh Creek d04536d09d Merge pull request #16 from jcreek/dependabot/github_actions/docker/setup-buildx-action-4
build(deps): bump docker/setup-buildx-action from 3 to 4
2026-03-12 23:52:21 +00:00
Josh Creek ad3dccbdd4 Merge pull request #17 from jcreek/dependabot/github_actions/docker/metadata-action-6
build(deps): bump docker/metadata-action from 5 to 6
2026-03-12 23:52:06 +00:00
Josh Creek 5bed9cafb8 Merge pull request #18 from jcreek/dependabot/github_actions/docker/build-push-action-7
build(deps): bump docker/build-push-action from 6 to 7
2026-03-12 23:51:48 +00:00
dependabot[bot] ca26ea9063 build(deps): bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 13:37:56 +00:00
dependabot[bot] b1a3f08d5e build(deps): bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 13:37:48 +00:00
dependabot[bot] 9ffd8c4750 build(deps): bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 13:37:44 +00:00
Josh Creek a192bcab06 Merge pull request #10 from jcreek/dependabot/github_actions/actions/setup-node-6
build(deps): bump actions/setup-node from 4 to 6
2026-03-05 21:35:48 +00:00
Josh Creek b107d5d357 Merge pull request #11 from jcreek/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 4 to 6
2026-03-05 21:35:23 +00:00
Josh Creek e6487f7d25 Merge pull request #12 from jcreek/dependabot/github_actions/cycjimmy/semantic-release-action-6
build(deps): bump cycjimmy/semantic-release-action from 5 to 6
2026-03-05 21:34:39 +00:00
Josh Creek 53059caf31 Merge pull request #13 from jcreek/dependabot/github_actions/docker/login-action-4
build(deps): bump docker/login-action from 3 to 4
2026-03-05 21:34:16 +00:00
Josh Creek 808fe5a440 Merge pull request #15 from jcreek/14-read-only-mode
fix(#14): surface explicit writable diagnostics in API and read-only …
2026-03-05 19:00:30 +00:00
Josh Creek c2bffede3e fix(#14): surface explicit writable diagnostics in API and read-only banner 2026-03-05 18:51:04 +00:00
dependabot[bot] 2fd4291bb9 build(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 13:37:51 +00:00
dependabot[bot] bf0eac88f4 build(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 23:57:56 +00:00
dependabot[bot] e7dd300098 build(deps): bump cycjimmy/semantic-release-action from 5 to 6
Bumps [cycjimmy/semantic-release-action](https://github.com/cycjimmy/semantic-release-action) from 5 to 6.
- [Release notes](https://github.com/cycjimmy/semantic-release-action/releases)
- [Changelog](https://github.com/cycjimmy/semantic-release-action/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/cycjimmy/semantic-release-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: cycjimmy/semantic-release-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 23:39:32 +00:00
dependabot[bot] d2826f4cb3 build(deps): bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 23:39:25 +00:00
9 changed files with 170 additions and 42 deletions
+4 -4
View File
@@ -16,14 +16,14 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: docker.io/${{ env.IMAGE_NAME }}
tags: |
@@ -31,7 +31,7 @@ jobs:
type=sha,prefix=sha-
- name: Build (PR validation only)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Preflight Docker Hub repository access
env:
+8 -8
View File
@@ -27,18 +27,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
- name: Create Git tag + GitHub release
id: semantic
uses: cycjimmy/semantic-release-action@v5
uses: cycjimmy/semantic-release-action@v6
with:
extra_plugins: |
@semantic-release/commit-analyzer
@@ -57,20 +57,20 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: docker.io/${{ env.IMAGE_NAME }}
tags: |
@@ -80,7 +80,7 @@ jobs:
type=sha,prefix=sha-
- name: Build and push release image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
+1
View File
@@ -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)
+19 -9
View File
@@ -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
});
+23 -2
View File
@@ -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';
+10
View File
@@ -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<NetworkFileMe
return (await writeNetworkFileCore(data)) as NetworkFileMetadata;
}
export async function getWritableState(): Promise<WritableState> {
return (await getWritableStateCore()) as WritableState;
}
export async function checkWritableState(): Promise<boolean> {
return checkWritableStateCore();
}
+84 -9
View File
@@ -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<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
* @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()) {
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<boolean>}
*/
export async function checkWritableState() {
return (await getWritableState()).writable;
}
+20 -9
View File
@@ -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
});