feat(#14): Add zero-setup Docker data initialization

This commit is contained in:
Josh Creek
2026-07-24 22:24:45 +01:00
parent 5976b46cad
commit 0b1d647aec
14 changed files with 509 additions and 72 deletions
+70 -43
View File
@@ -57,6 +57,8 @@
| null;
const autosaveDelayMs = 800;
const staticDemoMode = import.meta.env.OND_STATIC_DEMO;
const staticDemoReason = 'Static demo deployment; changes are not persisted.';
let container: HTMLDivElement;
let diagramStage: HTMLDivElement;
@@ -299,13 +301,19 @@
let subnetEditorOpen = false;
let showRackManager = false;
// First-run card: shown for an empty dataset or the untouched example
// file, until dismissed (persisted) or the user makes their first edit.
// Writable empty datasets use a session-only dismissal so onboarding
// returns after reload until the user adds a real entity. Sample-data
// dismissal remains persisted for returning demo users.
const firstRunDismissKey = 'ond-firstrun-dismissed';
let firstRunDismissed =
typeof localStorage !== 'undefined' && localStorage.getItem(firstRunDismissKey) === '1';
let emptyFirstRunDismissedThisSession = false;
function dismissFirstRun() {
if (writable && totalEntities === 0) {
emptyFirstRunDismissedThisSession = true;
return;
}
firstRunDismissed = true;
try {
localStorage.setItem(firstRunDismissKey, '1');
@@ -329,9 +337,9 @@
].every((name) => name.trim().toLowerCase().startsWith('example'));
$: showFirstRun =
hasLoadedInitialData &&
!firstRunDismissed &&
!isLoadingData &&
(totalEntities === 0 || (totalEntities <= 4 && allExampleNames));
((writable && totalEntities === 0 && !emptyFirstRunDismissedThisSession) ||
(!firstRunDismissed && totalEntities <= 4 && allExampleNames));
let copiedIp: string | null = null;
let copiedIpTimer: ReturnType<typeof setTimeout> | null = null;
let pendingSubnetRemoval: number | null = null;
@@ -695,6 +703,13 @@
return `Read-only: ${reason}`;
}
function resolveStaticFallbackNotice(reason: string | null): string {
if (!reason) {
return 'Read-only: API unavailable; using bundled static data.';
}
return `Read-only: ${reason} Using bundled static data.`;
}
// Nodes are invisible hit targets (interaction, layout, edge anchoring);
// the visible cards live in NodeCardLayer. Only edges are canvas-drawn,
// styled from the same CSS tokens as the chrome.
@@ -1257,6 +1272,7 @@
return;
}
firstRunDismissed = true;
emptyFirstRunDismissedThisSession = true;
saveError = null;
saveState = 'unsaved';
if (!writable) {
@@ -1898,46 +1914,56 @@
isLoadingData = true;
loadError = null;
saveError = null;
let apiFallbackReason: string | null = staticDemoMode ? staticDemoReason : null;
try {
const response = await fetch('/api/network-data', {
headers: { Accept: 'application/json' }
});
if (!response.ok) {
throw new Error(`API responded with ${response.status}`);
}
const body = (await response.json()) as {
data: NetworkData;
writable: boolean;
writableReason?: string | null;
source: string;
updatedAt: string;
};
const validation = validateNetworkData(body.data);
if (!validation.valid || !validation.data) {
throw new Error('API returned invalid network data');
}
if (!staticDemoMode) {
try {
const response = await fetch('/api/network-data', {
headers: { Accept: 'application/json' }
});
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as {
error?: string;
writableReason?: string | null;
};
apiFallbackReason =
body.writableReason ?? body.error ?? `API responded with ${response.status}`;
throw new Error(apiFallbackReason);
}
const body = (await response.json()) as {
data: NetworkData;
writable: boolean;
writableReason?: string | null;
source: string;
updatedAt: string;
};
const validation = validateNetworkData(body.data);
if (!validation.valid || !validation.data) {
throw new Error('API returned invalid network data');
}
networkData = cloneNetworkData(validation.data);
ensureSelectedTargetValid();
writable = body.writable;
readOnlyNotice = body.writable
? defaultReadOnlyNotice
: resolveReadOnlyNotice(body.writableReason);
dataSourceLabel = body.source;
lastSavedSnapshot = JSON.stringify(networkData);
saveState = 'saved';
connectionDraftByKey = {};
revalidateDraft();
refreshGraph();
hasLoadedInitialData = true;
isLoadingData = false;
return;
} catch (apiError) {
console.warn(
'[OpenNetworkDiagram] API load unavailable, falling back to static JSON',
apiError
);
networkData = cloneNetworkData(validation.data);
ensureSelectedTargetValid();
writable = body.writable;
readOnlyNotice = body.writable
? defaultReadOnlyNotice
: resolveReadOnlyNotice(body.writableReason);
dataSourceLabel = body.source;
lastSavedSnapshot = JSON.stringify(networkData);
saveState = 'saved';
connectionDraftByKey = {};
revalidateDraft();
refreshGraph();
hasLoadedInitialData = true;
isLoadingData = false;
return;
} catch (apiError) {
apiFallbackReason ??= resolveErrorMessage(apiError);
console.warn(
'[OpenNetworkDiagram] API load unavailable, falling back to static JSON',
apiError
);
}
}
try {
@@ -1945,7 +1971,7 @@
networkData = cloneNetworkData(fallbackData);
ensureSelectedTargetValid();
writable = false;
readOnlyNotice = 'Read-only: API unavailable; using bundled static data.';
readOnlyNotice = resolveStaticFallbackNotice(apiFallbackReason);
dataSourceLabel = jsonPath;
lastSavedSnapshot = JSON.stringify(networkData);
saveState = 'saved';
@@ -2501,6 +2527,7 @@
isSampleData={allExampleNames}
{writable}
hasMachines={networkData.machines.length > 0}
storageReady={writable && totalEntities === 0}
on:addmachine={addMachine}
on:connectport={openFirstMachineEditor}
on:openipam={() => (showIpamPanel = true)}