mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 06:58:48 -05:00
fixed a race condition in state updates during diagram loading, added loading protection to stop concurrent loads, added icon saving to the backend which *could* have been causing issues with loads potentially, now icons are saved. Added in timeouts for fetch requests too that weren't present
This commit is contained in:
@@ -356,27 +356,51 @@ function App() {
|
||||
|
||||
|
||||
const handleDiagramManagerLoad = (id: string, data: any) => {
|
||||
// Load diagram from server storage
|
||||
// Server storage contains ALL icons (including imported), so use them directly
|
||||
console.log(`App: handleDiagramManagerLoad called for diagram ${id}`);
|
||||
|
||||
/**
|
||||
* Icon Persistence Strategy:
|
||||
*
|
||||
* NEW BEHAVIOR (after this fix):
|
||||
* - Server storage saves ALL icons (default collections + imported custom icons)
|
||||
* - When loading, if we detect default collection icons, use ALL icons from server
|
||||
* - This preserves imported custom icons without data loss
|
||||
*
|
||||
* BACKWARD COMPATIBILITY (for old saves):
|
||||
* - Old format only saved imported icons (collection='imported')
|
||||
* - If no default icons detected, merge imported icons with current defaults
|
||||
* - This ensures old diagrams still load correctly
|
||||
*
|
||||
* DETECTION:
|
||||
* - Check if loaded icons contain any default collection (isoflow, aws, gcp, etc.)
|
||||
* - If yes: New format, use all icons from server
|
||||
* - If no: Old format, merge imported with defaults
|
||||
*/
|
||||
const loadedIcons = data.icons || [];
|
||||
|
||||
// Check if we have all default icons in the loaded data
|
||||
const hasAllDefaults = icons.every(defaultIcon =>
|
||||
loadedIcons.some((loadedIcon: any) => loadedIcon.id === defaultIcon.id)
|
||||
);
|
||||
|
||||
// If the saved data has all icons, use it as-is
|
||||
// Otherwise, merge imported icons with defaults (for backward compatibility)
|
||||
console.log(`App: Server sent ${loadedIcons.length} icons`);
|
||||
|
||||
// Strategy: Check if server has ALL icons (both default and imported)
|
||||
// Server storage now saves ALL icons, so we should use them directly
|
||||
// For backward compatibility with old saves, we detect and merge
|
||||
|
||||
let finalIcons;
|
||||
if (hasAllDefaults) {
|
||||
// Server saved all icons, use them directly
|
||||
const hasDefaultIcons = loadedIcons.some((icon: any) =>
|
||||
icon.collection === 'isoflow' || icon.collection === 'aws' || icon.collection === 'gcp'
|
||||
);
|
||||
|
||||
if (hasDefaultIcons) {
|
||||
// New format: Server saved ALL icons (default + imported)
|
||||
// Use them directly to preserve any custom icon modifications
|
||||
console.log(`App: Using all ${loadedIcons.length} icons from server (includes defaults + imported)`);
|
||||
finalIcons = loadedIcons;
|
||||
} else {
|
||||
// Old format or session storage - merge imported with defaults
|
||||
// Old format: Server only saved imported icons
|
||||
// Merge imported icons with current default icons
|
||||
const importedIcons = loadedIcons.filter((icon: any) => icon.collection === 'imported');
|
||||
finalIcons = [...icons, ...importedIcons];
|
||||
console.log(`App: Old format detected. Merged ${importedIcons.length} imported icons with ${icons.length} defaults = ${finalIcons.length} total`);
|
||||
}
|
||||
|
||||
|
||||
const mergedData: DiagramData = {
|
||||
...data,
|
||||
title: data.title || data.name || 'Loaded Diagram',
|
||||
@@ -384,19 +408,34 @@ function App() {
|
||||
colors: data.colors?.length ? data.colors : defaultColors,
|
||||
fitToScreen: data.fitToScreen !== false
|
||||
};
|
||||
|
||||
setDiagramData(mergedData);
|
||||
setDiagramName(data.name || 'Loaded Diagram');
|
||||
setCurrentModel(mergedData);
|
||||
setCurrentDiagram({
|
||||
|
||||
const newDiagram = {
|
||||
id,
|
||||
name: data.name || 'Loaded Diagram',
|
||||
data: mergedData,
|
||||
createdAt: data.created || new Date().toISOString(),
|
||||
updatedAt: data.lastModified || new Date().toISOString()
|
||||
});
|
||||
setFossflowKey(prev => prev + 1); // Force re-render
|
||||
};
|
||||
|
||||
console.log(`App: Setting all state for diagram ${id}`);
|
||||
|
||||
// Use a single batch of state updates to minimize re-render issues
|
||||
// Update diagram data and increment key in the same render cycle
|
||||
setDiagramName(newDiagram.name);
|
||||
setCurrentDiagram(newDiagram);
|
||||
setCurrentModel(mergedData);
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Update diagramData and key together
|
||||
// This ensures Isoflow gets the correct data with the new key
|
||||
setDiagramData(mergedData);
|
||||
setFossflowKey(prev => {
|
||||
const newKey = prev + 1;
|
||||
console.log(`App: Updated fossflowKey from ${prev} to ${newKey}`);
|
||||
return newKey;
|
||||
});
|
||||
|
||||
console.log(`App: Finished loading diagram ${id}, final icon count: ${finalIcons.length}`);
|
||||
};
|
||||
|
||||
// i18n
|
||||
|
||||
@@ -55,12 +55,25 @@ export const DiagramManager: React.FC<Props> = ({
|
||||
|
||||
const handleLoad = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log(`DiagramManager: Loading diagram ${id}...`);
|
||||
|
||||
const storage = storageManager.getStorage();
|
||||
const data = await storage.loadDiagram(id);
|
||||
|
||||
console.log(`DiagramManager: Successfully loaded diagram ${id}`);
|
||||
onLoadDiagram(id, data);
|
||||
|
||||
// Small delay to ensure parent component finishes state updates
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(`DiagramManager: Failed to load diagram ${id}:`, err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load diagram');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,12 +99,12 @@ export const DiagramManager: React.FC<Props> = ({
|
||||
|
||||
try {
|
||||
const storage = storageManager.getStorage();
|
||||
|
||||
|
||||
// Check if a diagram with this name already exists (excluding current diagram)
|
||||
const existingDiagram = diagrams.find(d =>
|
||||
const existingDiagram = diagrams.find(d =>
|
||||
d.name === saveName.trim() && d.id !== currentDiagramId
|
||||
);
|
||||
|
||||
|
||||
if (existingDiagram) {
|
||||
const confirmOverwrite = window.confirm(
|
||||
`A diagram named "${saveName}" already exists. This will overwrite it. Are you sure you want to continue?`
|
||||
@@ -99,16 +112,30 @@ export const DiagramManager: React.FC<Props> = ({
|
||||
if (!confirmOverwrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Delete the existing diagram first
|
||||
await storage.deleteDiagram(existingDiagram.id);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Icon Persistence: Save ALL icons (default + imported)
|
||||
*
|
||||
* currentDiagramData comes from parent's currentModel/diagramData which includes:
|
||||
* - All default icon collections (isoflow, aws, gcp, azure, kubernetes)
|
||||
* - All imported custom icons (collection='imported')
|
||||
*
|
||||
* This ensures when loading, we have the complete icon set and don't lose
|
||||
* any custom imported icons.
|
||||
*/
|
||||
const dataToSave = {
|
||||
...currentDiagramData,
|
||||
name: saveName
|
||||
};
|
||||
|
||||
console.log(`DiagramManager: Saving diagram with ${dataToSave.icons?.length || 0} icons`);
|
||||
const importedCount = (dataToSave.icons || []).filter((icon: any) => icon.collection === 'imported').length;
|
||||
console.log(`DiagramManager: Including ${importedCount} imported icons`);
|
||||
|
||||
if (currentDiagramId) {
|
||||
// Update existing
|
||||
await storage.saveDiagram(currentDiagramId, dataToSave);
|
||||
@@ -182,15 +209,17 @@ export const DiagramManager: React.FC<Props> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="diagram-actions">
|
||||
<button
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => handleLoad(diagram.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
Load
|
||||
{loading ? 'Loading...' : 'Load'}
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="action-button danger"
|
||||
onClick={() => handleDelete(diagram.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface StorageService {
|
||||
class ServerStorage implements StorageService {
|
||||
private baseUrl: string;
|
||||
private available: boolean | null = null;
|
||||
private availabilityCheckedAt: number | null = null;
|
||||
private readonly AVAILABILITY_CACHE_MS = 60000; // Re-check every 60 seconds
|
||||
|
||||
constructor(baseUrl: string = '') {
|
||||
// In production (Docker), use relative paths (nginx proxy)
|
||||
@@ -29,16 +31,29 @@ class ServerStorage implements StorageService {
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (this.available !== null) return this.available;
|
||||
// Re-check availability if cache is stale
|
||||
const now = Date.now();
|
||||
if (this.available !== null &&
|
||||
this.availabilityCheckedAt !== null &&
|
||||
(now - this.availabilityCheckedAt) < this.AVAILABILITY_CACHE_MS) {
|
||||
return this.available;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/storage/status`);
|
||||
const response = await fetch(`${this.baseUrl}/api/storage/status`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000) // 5 second timeout
|
||||
});
|
||||
const data = await response.json();
|
||||
this.available = data.enabled;
|
||||
return this.available;
|
||||
this.availabilityCheckedAt = Date.now();
|
||||
console.log(`Server storage availability: ${this.available}`);
|
||||
return this.available ?? false;
|
||||
} catch (error) {
|
||||
console.log('Server storage not available:', error);
|
||||
this.available = false;
|
||||
this.availabilityCheckedAt = Date.now();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -64,18 +79,50 @@ class ServerStorage implements StorageService {
|
||||
}
|
||||
|
||||
async loadDiagram(id: string): Promise<Model> {
|
||||
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`);
|
||||
if (!response.ok) throw new Error('Failed to load diagram');
|
||||
return response.json();
|
||||
console.log(`ServerStorage: Loading diagram ${id} from ${this.baseUrl}/api/diagrams/${id}`);
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000) // 10 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`ServerStorage: Failed to load diagram ${id}: ${response.status} ${errorText}`);
|
||||
throw new Error(`Failed to load diagram: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`ServerStorage: Successfully loaded diagram ${id}, items: ${data.items?.length || 0}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`ServerStorage: Error loading diagram ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveDiagram(id: string, data: Model): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save diagram');
|
||||
console.log(`ServerStorage: Saving diagram ${id}`);
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
signal: AbortSignal.timeout(15000) // 15 second timeout for saves
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`ServerStorage: Failed to save diagram ${id}: ${response.status} ${errorText}`);
|
||||
throw new Error(`Failed to save diagram: ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`ServerStorage: Successfully saved diagram ${id}`);
|
||||
} catch (error) {
|
||||
console.error(`ServerStorage: Error saving diagram ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDiagram(id: string): Promise<void> {
|
||||
|
||||
@@ -110,15 +110,24 @@ if (STORAGE_ENABLED) {
|
||||
|
||||
// Get specific diagram
|
||||
app.get('/api/diagrams/:id', async (req, res) => {
|
||||
const diagramId = req.params.id;
|
||||
console.log(`[GET /api/diagrams/${diagramId}] Loading diagram...`);
|
||||
|
||||
try {
|
||||
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
|
||||
const filePath = path.join(STORAGE_PATH, `${diagramId}.json`);
|
||||
console.log(`[GET /api/diagrams/${diagramId}] Reading from: ${filePath}`);
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
res.json(JSON.parse(content));
|
||||
const data = JSON.parse(content);
|
||||
|
||||
console.log(`[GET /api/diagrams/${diagramId}] Successfully loaded, size: ${content.length} bytes, items: ${data.items?.length || 0}`);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.error(`[GET /api/diagrams/${diagramId}] Diagram not found`);
|
||||
res.status(404).json({ error: 'Diagram not found' });
|
||||
} else {
|
||||
console.error('Error reading diagram:', error);
|
||||
console.error(`[GET /api/diagrams/${diagramId}] Error reading diagram:`, error);
|
||||
res.status(500).json({ error: 'Failed to read diagram' });
|
||||
}
|
||||
}
|
||||
@@ -126,25 +135,34 @@ if (STORAGE_ENABLED) {
|
||||
|
||||
// Save or update diagram
|
||||
app.put('/api/diagrams/:id', async (req, res) => {
|
||||
const diagramId = req.params.id;
|
||||
console.log(`[PUT /api/diagrams/${diagramId}] Saving diagram...`);
|
||||
|
||||
try {
|
||||
const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`);
|
||||
const filePath = path.join(STORAGE_PATH, `${diagramId}.json`);
|
||||
const data = {
|
||||
...req.body,
|
||||
id: req.params.id,
|
||||
id: diagramId,
|
||||
lastModified: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
const iconCount = data.icons?.length || 0;
|
||||
const importedIconCount = (data.icons || []).filter(icon => icon.collection === 'imported').length;
|
||||
console.log(`[PUT /api/diagrams/${diagramId}] Writing to: ${filePath}`);
|
||||
console.log(`[PUT /api/diagrams/${diagramId}] Items: ${data.items?.length || 0}, Icons: ${iconCount} (${importedIconCount} imported)`);
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
|
||||
|
||||
console.log(`[PUT /api/diagrams/${diagramId}] Successfully saved`);
|
||||
|
||||
// Git backup if enabled
|
||||
if (ENABLE_GIT_BACKUP) {
|
||||
// TODO: Implement git commit
|
||||
console.log('Git backup not yet implemented');
|
||||
console.log('[PUT] Git backup not yet implemented');
|
||||
}
|
||||
|
||||
res.json({ success: true, id: req.params.id });
|
||||
|
||||
res.json({ success: true, id: diagramId });
|
||||
} catch (error) {
|
||||
console.error('Error saving diagram:', error);
|
||||
console.error(`[PUT /api/diagrams/${diagramId}] Error saving diagram:`, error);
|
||||
res.status(500).json({ error: 'Failed to save diagram' });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user