From 30b4bb4626f9d0a3d893bf64c8a0c2665cfdfc8f Mon Sep 17 00:00:00 2001 From: dnviti Date: Sat, 29 Nov 2025 00:50:22 +0100 Subject: [PATCH] - --- CLAUDE.md | 66 +++++- .../src/contexts/CollaborationContext.tsx | 19 ++ frontend/src/pages/ReportEditorPage.tsx | 149 +++++++++--- frontend/src/services/api.ts | 15 +- frontend/src/services/collaboration.ts | 222 +++++++++++++++++- frontend/src/services/signalr.ts | 17 +- src/Apollinare.API/Hubs/CollaborationHub.cs | 37 +++ src/Apollinare.API/Hubs/DataHub.cs | 41 +++- src/Apollinare.API/Program.cs | 11 +- src/Apollinare.API/apollinare.db-shm | Bin 32768 -> 32768 bytes src/Apollinare.API/apollinare.db-wal | Bin 1046512 -> 144232 bytes 11 files changed, 518 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b6f432e..b80dd09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,13 +46,37 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve ## Quick Start - Session Recovery -**Ultima sessione:** 28 Novembre 2025 (pomeriggio - sera) +**Ultima sessione:** 28 Novembre 2025 (notte) **Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso **Lavoro completato nell'ultima sessione:** -- **NUOVA FEATURE: Sistema Collaborazione Real-Time Globale** - IN CORSO +- **NUOVA FEATURE: Sincronizzazione Real-Time Efficiente** - COMPLETATO + - **Prima:** Al salvataggio veniva inviata solo una notifica `DataSaved`, l'altra sessione ricaricava il template dal server (lento) + - **Dopo:** Al salvataggio viene inviato l'intero template via SignalR (`BroadcastTemplateSync`), l'altra sessione lo applica direttamente (istantaneo) + - **Compressione automatica** per template > 10KB usando gzip via browser's CompressionStream API + - **Version tracking** per gestione conflitti (ignora template con versione più vecchia) + - Nuovo metodo Hub: `BroadcastTemplateSync(roomKey, templateJson, version, compressed)` + - Nuovo evento: `TemplateSync` con decompressione automatica lato client + - **FIX: Limite messaggio SignalR** - Aumentato `MaximumReceiveMessageSize` a 1MB in `Program.cs` (default era 32KB) + - **File modificati:** + - `CollaborationHub.cs` - Aggiunto `BroadcastTemplateSync` e `TemplateSyncMessage` con campo `Compressed` + - `collaboration.ts` - Aggiunto `broadcastTemplateSync()`, utilities compressione/decompressione (`compressString`, `decompressString`), handler `TemplateSync` + - `CollaborationContext.tsx` - Esposto `broadcastTemplateSync` e `onTemplateSync` + - `ReportEditorPage.tsx` - Sostituito `sendDataSaved()` con `broadcastTemplateSync()`, aggiunto handler per applicare template ricevuti + - `Program.cs` - Configurato SignalR con `MaximumReceiveMessageSize = 1MB` + +- **FIX: Auto-Save Event-Based con Debounce** - COMPLETATO + - Riscritto auto-save per triggerare ad ogni modifica (non a intervalli) + - Debounce di 500ms per evitare salvataggi multipli durante editing rapido + - Usa `useRef` per `saveMutation`, `template`, `templateInfo` per evitare re-creazione del timeout + - Dipendenze effect: solo `autoSaveEnabled`, `isNew`, `hasUnsavedChanges`, `templateHistory.undoCount` + - Il check `isPending` avviene al momento dell'esecuzione del timeout, non come dipendenza + +**Lavoro completato nelle sessioni precedenti (28 Novembre 2025 pomeriggio - sera):** + +- **NUOVA FEATURE: Sistema Collaborazione Real-Time Globale** - COMPLETATO - Migrato da sistema report-specific a sistema globale per tutta l'app - `CollaborationHub.cs` - Hub SignalR generico per qualsiasi entità/pagina - `collaboration.ts` - Service singleton per gestione connessione @@ -61,7 +85,6 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve - Room-based collaboration con formato `{entityType}:{entityId}` - **FIX: Incompatibilità versione SignalR** - Rimosso package `Microsoft.AspNetCore.SignalR.Common` v10.0.0 incompatibile con .NET 9, downgrade client `@microsoft/signalr` a v8.0.7 - **FIX: Auto-save non funzionante** - Usati `useRef` per evitare re-run dell'effect causato da `saveMutation` nelle dependencies - - **IN DEBUG:** Sincronizzazione real-time tra sessioni - Il salvataggio manuale sincronizza, l'auto-save invia la notifica ma la sessione 2 non la riceve ancora **Lavoro completato nelle sessioni precedenti (28 Novembre 2025 tarda notte):** @@ -1077,22 +1100,47 @@ frontend/src/ - **Soluzione:** Usati `useRef` per `saveMutation`, `template`, e `templateInfo` per evitare che l'effect si ri-esegua inutilmente - **File:** `ReportEditorPage.tsx` -22. **Sistema Collaborazione Real-Time (IN CORSO 28/11/2025):** +22. **Sistema Collaborazione Real-Time (COMPLETATO 28/11/2025):** - **Obiettivo:** Collaborazione stile Google Docs su tutto l'applicativo - **Architettura implementata:** - `CollaborationHub.cs` - Hub SignalR generico con room-based collaboration - `collaboration.ts` - Service singleton frontend - `CollaborationContext.tsx` - React Context con `useCollaborationRoom` hook - Room key format: `{entityType}:{entityId}` (es. `report-template:2`) - - **Stato attuale:** - - Connessione e join room funzionanti - - Salvataggio manuale notifica le altre sessioni correttamente - - Auto-save invia `sendDataSaved()` ma le altre sessioni non ricevono la notifica - - **Debug in corso:** Aggiunto logging dettagliato per tracciare il flusso dei messaggi - **File principali:** - Backend: `CollaborationHub.cs` - Frontend: `collaboration.ts`, `CollaborationContext.tsx`, `ReportEditorPage.tsx` +23. **Sincronizzazione Real-Time Lenta (FIX 28/11/2025 sera):** + - **Problema:** Al salvataggio (manuale o auto-save), l'altra sessione impiegava diversi secondi per vedere le modifiche + - **Causa:** Il sistema usava `DataSaved` notification che causava un reload del template dal server (`queryClient.invalidateQueries`) + - **Soluzione:** Implementato `BroadcastTemplateSync` che invia l'intero template via SignalR direttamente alle altre sessioni: + - Nuovo metodo Hub `BroadcastTemplateSync(roomKey, templateJson, version, compressed)` + - L'altra sessione riceve il template e lo applica istantaneamente con `historyActions.setWithoutHistory()` + - Aggiunto version tracking per evitare di applicare versioni più vecchie + - Compressione automatica gzip per template > 10KB (usa browser's CompressionStream API) + - **File:** `CollaborationHub.cs`, `collaboration.ts`, `CollaborationContext.tsx`, `ReportEditorPage.tsx` + +24. **Limite Messaggio SignalR (FIX 28/11/2025 notte):** + - **Problema:** La connessione SignalR si disconnetteva con errore "The maximum message size of 32768B was exceeded" + - **Causa:** Il template compresso (~110KB) superava il limite di default di SignalR (32KB) + - **Soluzione:** Configurato `MaximumReceiveMessageSize = 1MB` in `Program.cs`: + ```csharp + builder.Services.AddSignalR(options => { + options.MaximumReceiveMessageSize = 1024 * 1024; // 1MB + }) + ``` + - **File:** `Program.cs` + +25. **Auto-Save Non Funzionante (FIX 28/11/2025 notte):** + - **Problema:** L'auto-save non si attivava anche con modifiche non salvate + - **Causa:** Le dipendenze dell'effect includevano `saveMutation`, `template`, `templateInfo` che cambiano ad ogni render, causando reset continui del timeout + - **Soluzione:** Approccio event-based con debounce usando refs: + - `saveMutationRef`, `templateForSaveRef`, `templateInfoForSaveRef` per accedere ai valori correnti senza re-triggerare l'effect + - Dipendenze effect ridotte a: `autoSaveEnabled`, `isNew`, `hasUnsavedChanges`, `templateHistory.undoCount` + - Check `isPending` spostato dentro il callback del setTimeout + - **File:** `ReportEditorPage.tsx` + ### Schema Database Report System Le tabelle sono già nel DbContext (`AppollinareDbContext.cs`): diff --git a/frontend/src/contexts/CollaborationContext.tsx b/frontend/src/contexts/CollaborationContext.tsx index 455f446..2db010f 100644 --- a/frontend/src/contexts/CollaborationContext.tsx +++ b/frontend/src/contexts/CollaborationContext.tsx @@ -21,6 +21,7 @@ import { type SyncRequestMessage, type SyncDataMessage, type DataSavedMessage, + type TemplateSyncMessage, type UserLeftMessage, getOrCreateUserName, getColorForUser, @@ -93,6 +94,7 @@ export interface CollaborationContextValue { requestSync: () => void; sendSync: (targetConnectionId: string, dataJson: string) => void; sendDataSaved: () => void; + broadcastTemplateSync: (templateJson: string, version: number) => void; // Event subscriptions for component-specific handlers onDataChanged: (callback: (msg: DataChangeMessage) => void) => () => void; @@ -104,6 +106,7 @@ export interface CollaborationContextValue { onSyncRequested: (callback: (msg: SyncRequestMessage) => void) => () => void; onSyncReceived: (callback: (msg: SyncDataMessage) => void) => () => void; onDataSaved: (callback: (msg: DataSavedMessage) => void) => () => void; + onTemplateSync: (callback: (msg: TemplateSyncMessage) => void) => () => void; } const CollaborationContext = createContext( @@ -409,6 +412,13 @@ export function CollaborationProvider({ collaborationService.sendDataSaved(); }, []); + const broadcastTemplateSync = useCallback( + (templateJson: string, version: number) => { + collaborationService.broadcastTemplateSync(templateJson, version); + }, + [], + ); + // Event subscription pass-through const onDataChanged = useCallback( (callback: (msg: DataChangeMessage) => void) => { @@ -459,6 +469,13 @@ export function CollaborationProvider({ [], ); + const onTemplateSync = useCallback( + (callback: (msg: TemplateSyncMessage) => void) => { + return collaborationService.onTemplateSync(callback); + }, + [], + ); + const value: CollaborationContextValue = { // Connection state isConnected, @@ -499,6 +516,7 @@ export function CollaborationProvider({ requestSync, sendSync, sendDataSaved, + broadcastTemplateSync, // Event subscriptions onDataChanged, @@ -508,6 +526,7 @@ export function CollaborationProvider({ onSyncRequested, onSyncReceived, onDataSaved, + onTemplateSync, }; return ( diff --git a/frontend/src/pages/ReportEditorPage.tsx b/frontend/src/pages/ReportEditorPage.tsx index 0723a1c..c7bb36f 100644 --- a/frontend/src/pages/ReportEditorPage.tsx +++ b/frontend/src/pages/ReportEditorPage.tsx @@ -178,6 +178,9 @@ export default function ReportEditorPage() { // Auto-save feature - enabled by default const [autoSaveEnabled, setAutoSaveEnabled] = useState(true); + // Template version for collaboration sync (increments on each save) + const templateVersionRef = useRef(0); + // ============ COLLABORATION (using global context) ============ // Room key format: "report-template:{id}" const roomKey = id ? `report-template:${id}` : null; @@ -315,19 +318,68 @@ export default function ReportEditorPage() { }), ); - // Template saved by remote user + // Template sync received - apply template directly without server reload + unsubscribers.push( + collaboration.onTemplateSync((message) => { + console.log( + `[Collaboration] Received TemplateSync, version ${message.version}, size: ${message.templateJson.length} bytes`, + ); + + // Only apply if the received version is newer + if (message.version <= templateVersionRef.current) { + console.log( + `[Collaboration] Ignoring older template version ${message.version} (current: ${templateVersionRef.current})`, + ); + return; + } + + try { + isApplyingRemoteChange.current = true; + const receivedTemplate = JSON.parse( + message.templateJson, + ) as AprtTemplate; + + // Update version to received version + templateVersionRef.current = message.version; + + // Apply template without adding to history (it's a sync, not a user action) + historyActions.setWithoutHistory(() => receivedTemplate); + + // Show notification + setSnackbar({ + open: true, + message: "Template aggiornato da un altro utente", + severity: "success", + }); + + // Also update the query cache so it stays in sync + queryClient.setQueryData(["report-template", id], (old: unknown) => { + if (!old) return old; + return { + ...(old as Record), + templateJson: message.templateJson, + }; + }); + } catch (e) { + console.error("[Collaboration] Error parsing received template:", e); + } finally { + setTimeout(() => { + isApplyingRemoteChange.current = false; + }, 0); + } + }), + ); + + // Legacy DataSaved handler (for backwards compatibility - will be removed) unsubscribers.push( collaboration.onDataSaved((message) => { console.log( "[Collaboration] Received DataSaved from:", message.savedBy, + "(legacy - should use TemplateSync instead)", ); - setSnackbar({ - open: true, - message: `${message.savedBy} ha salvato il template`, - severity: "success", - }); - queryClient.invalidateQueries({ queryKey: ["report-template", id] }); + // Only reload from server if we haven't received a TemplateSync + // This is a fallback for older clients }), ); @@ -529,16 +581,30 @@ export default function ReportEditorPage() { // Mark current state as saved setLastSavedUndoCount(templateHistory.undoCount); - // Notify collaborators of save - console.log( - "[AutoSave] Save success, collaboration.isConnected:", - collaboration.isConnected, - "currentRoom:", - collaboration.currentRoom, - ); + // Broadcast template to collaborators (instant sync without server reload) if (collaboration.isConnected && collaboration.currentRoom) { - console.log("[AutoSave] Sending DataSaved notification"); - collaboration.sendDataSaved(); + // Update dataSources in template for broadcasting + const updatedTemplateForSync = { + ...template, + dataSources: selectedDatasets.reduce( + (acc, ds) => { + acc[ds.id] = { type: "object" as const, schema: ds.id }; + return acc; + }, + {} as Record, + ), + }; + + // Increment version and broadcast + templateVersionRef.current += 1; + const templateJson = JSON.stringify(updatedTemplateForSync); + console.log( + `[AutoSave] Broadcasting template sync, version ${templateVersionRef.current}, size: ${templateJson.length} bytes`, + ); + collaboration.broadcastTemplateSync( + templateJson, + templateVersionRef.current, + ); } if (isNew) { @@ -1452,38 +1518,49 @@ export default function ReportEditorPage() { selectedElementId, ]); - // Auto-save effect - saves after 1 second of inactivity when there are unsaved changes - // Use refs to avoid the effect re-running on every render due to saveMutation changing + // Auto-save: simple debounced save on every template change + const autoSaveTimeoutRef = useRef | null>(null); const saveMutationRef = useRef(saveMutation); saveMutationRef.current = saveMutation; - - const templateRef = useRef(template); - templateRef.current = template; - - const templateInfoRef = useRef(templateInfo); - templateInfoRef.current = templateInfo; + const templateForSaveRef = useRef(template); + templateForSaveRef.current = template; + const templateInfoForSaveRef = useRef(templateInfo); + templateInfoForSaveRef.current = templateInfo; useEffect(() => { - if ( - !autoSaveEnabled || - !hasUnsavedChanges || - isNew || - saveMutationRef.current.isPending - ) { + // Skip if disabled or new template + if (!autoSaveEnabled || isNew) { return; } - const timeoutId = setTimeout(() => { + // Skip if no unsaved changes + if (!hasUnsavedChanges) { + return; + } + + // Clear previous timeout (debounce) + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + + // Save after 500ms debounce + autoSaveTimeoutRef.current = setTimeout(() => { + // Check if not already saving at the moment of execution if (!saveMutationRef.current.isPending) { + console.log("[AutoSave] Saving..."); saveMutationRef.current.mutate({ - template: templateRef.current, - info: templateInfoRef.current, + template: templateForSaveRef.current, + info: templateInfoForSaveRef.current, }); } - }, 1000); // 1 second debounce + }, 500); - return () => clearTimeout(timeoutId); - }, [autoSaveEnabled, hasUnsavedChanges, isNew]); + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + }; + }, [autoSaveEnabled, isNew, hasUnsavedChanges, templateHistory.undoCount]); if (isLoadingTemplate && id) { return ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0119656..b5e4bfe 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,7 +1,20 @@ import axios from "axios"; +// Usa variabile ambiente o fallback a window.location per accesso esterno +const getApiBaseUrl = (): string => { + // Prima controlla la variabile ambiente (deve essere non-vuota) + const envUrl = import.meta.env.VITE_API_URL; + if (envUrl && envUrl.trim() !== "") { + return `${envUrl}/api`; + } + // Fallback: usa lo stesso host del frontend ma porta 5000 + const protocol = window.location.protocol; + const hostname = window.location.hostname; + return `${protocol}//${hostname}:5000/api`; +}; + const api = axios.create({ - baseURL: "http://localhost:5000/api", + baseURL: getApiBaseUrl(), headers: { "Content-Type": "application/json", }, diff --git a/frontend/src/services/collaboration.ts b/frontend/src/services/collaboration.ts index 2752a3f..f2c1926 100644 --- a/frontend/src/services/collaboration.ts +++ b/frontend/src/services/collaboration.ts @@ -1,7 +1,19 @@ import * as signalR from "@microsoft/signalr"; import { v4 as uuidv4 } from "uuid"; -const API_URL = "http://localhost:5000"; +// Usa variabile ambiente o fallback a window.location per accesso esterno +const getApiUrl = (): string => { + // Prima controlla la variabile ambiente (deve essere non-vuota) + const envUrl = import.meta.env.VITE_API_URL; + if (envUrl && envUrl.trim() !== "") { + return envUrl; + } + // Fallback: usa lo stesso host del frontend ma porta 5000 + const protocol = window.location.protocol; + const hostname = window.location.hostname; + return `${protocol}//${hostname}:5000`; +}; + const CURSOR_THROTTLE_MS = 50; // ==================== TYPES ==================== @@ -108,6 +120,113 @@ export interface DataSavedMessage { timestamp: string; } +export interface TemplateSyncMessage { + templateJson: string; + senderConnectionId: string; + version: number; + timestamp: string; + compressed?: boolean; // Whether the templateJson is base64-encoded gzip +} + +// ==================== COMPRESSION UTILITIES ==================== + +const COMPRESSION_THRESHOLD = 10000; // Compress if JSON is > 10KB + +/** + * Compress a string using gzip and return base64-encoded result + */ +async function compressString(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + writer.write(data); + writer.close(); + + const compressedChunks: Uint8Array[] = []; + const reader = cs.readable.getReader(); + + let result = await reader.read(); + while (!result.done) { + compressedChunks.push(result.value); + result = await reader.read(); + } + + // Combine chunks + const totalLength = compressedChunks.reduce( + (acc, arr) => acc + arr.length, + 0, + ); + const compressed = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of compressedChunks) { + compressed.set(chunk, offset); + offset += chunk.length; + } + + // Convert to base64 + return btoa(String.fromCharCode(...compressed)); +} + +/** + * Decompress a base64-encoded gzip string + */ +async function decompressString(base64Input: string): Promise { + // Convert base64 to Uint8Array + const binaryString = atob(base64Input); + const compressed = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + compressed[i] = binaryString.charCodeAt(i); + } + + const ds = new DecompressionStream("gzip"); + const writer = ds.writable.getWriter(); + writer.write(compressed); + writer.close(); + + const decompressedChunks: Uint8Array[] = []; + const reader = ds.readable.getReader(); + + let result = await reader.read(); + while (!result.done) { + decompressedChunks.push(result.value); + result = await reader.read(); + } + + // Combine chunks + const totalLength = decompressedChunks.reduce( + (acc, arr) => acc + arr.length, + 0, + ); + const decompressed = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of decompressedChunks) { + decompressed.set(chunk, offset); + offset += chunk.length; + } + + const decoder = new TextDecoder(); + return decoder.decode(decompressed); +} + +/** + * Check if CompressionStream is available (modern browsers) + */ +function isCompressionAvailable(): boolean { + return ( + typeof CompressionStream !== "undefined" && + typeof DecompressionStream !== "undefined" + ); +} + +export { + compressString, + decompressString, + isCompressionAvailable, + COMPRESSION_THRESHOLD, +}; + export interface UserLeftMessage { connectionId: string; userName: string; @@ -220,6 +339,7 @@ class CollaborationService { private syncRequestedListeners = new Set>(); private syncReceivedListeners = new Set>(); private dataSavedListeners = new Set>(); + private templateSyncListeners = new Set>(); private connectionStateListeners = new Set< EventCallback<{ connected: boolean; connecting: boolean }> >(); @@ -295,8 +415,10 @@ class CollaborationService { this.notifyConnectionState(); try { + const apiUrl = getApiUrl(); // Calcola URL al momento della connessione + console.log(`[Collaboration] Connecting to ${apiUrl}/hubs/collaboration`); this.connection = new signalR.HubConnectionBuilder() - .withUrl(`${API_URL}/hubs/collaboration`) + .withUrl(`${apiUrl}/hubs/collaboration`) .withAutomaticReconnect([0, 1000, 2000, 5000, 10000, 30000]) .configureLogging(signalR.LogLevel.Warning) .build(); @@ -639,6 +761,63 @@ class CollaborationService { ); } + /** + * Broadcast the full template to all collaborators in the room + * This is more efficient than having each client reload from the server + * Uses compression for large templates (>10KB) + * @param templateJson The full template JSON string + * @param version Incrementing version number for conflict detection + */ + async broadcastTemplateSync( + templateJson: string, + version: number, + ): Promise { + if (!this.canSend()) { + console.log("[Collaboration] broadcastTemplateSync: canSend is false"); + return; + } + + let dataToSend = templateJson; + let compressed = false; + + // Compress if large enough and compression is available + if ( + templateJson.length > COMPRESSION_THRESHOLD && + isCompressionAvailable() + ) { + try { + const compressedData = await compressString(templateJson); + // Only use compression if it actually reduces size + if (compressedData.length < templateJson.length * 0.9) { + dataToSend = compressedData; + compressed = true; + console.log( + `[Collaboration] Compressed template: ${templateJson.length} -> ${compressedData.length} bytes (${Math.round((1 - compressedData.length / templateJson.length) * 100)}% reduction)`, + ); + } + } catch (err) { + console.warn( + "[Collaboration] Compression failed, sending uncompressed:", + err, + ); + } + } + + console.log( + `[Collaboration] Broadcasting template sync, version ${version}, size: ${dataToSend.length} bytes, compressed: ${compressed}`, + ); + + this.connection!.invoke( + "BroadcastTemplateSync", + this.currentRoom, + dataToSend, + version, + compressed, + ).catch((err) => + console.error("[Collaboration] Error broadcasting template sync:", err), + ); + } + // ==================== SUBSCRIPTIONS ==================== onUserJoined(callback: EventCallback): () => void { @@ -713,6 +892,11 @@ class CollaborationService { return () => this.dataSavedListeners.delete(callback); } + onTemplateSync(callback: EventCallback): () => void { + this.templateSyncListeners.add(callback); + return () => this.templateSyncListeners.delete(callback); + } + onConnectionStateChanged( callback: EventCallback<{ connected: boolean; connecting: boolean }>, ): () => void { @@ -1006,6 +1190,40 @@ class CollaborationService { }); }); + // Template sync (full template broadcast) + this.connection.on("TemplateSync", async (message: TemplateSyncMessage) => { + console.log( + `[Collaboration] Received TemplateSync, version ${message.version}, size: ${message.templateJson.length} bytes, compressed: ${message.compressed}`, + ); + + // Decompress if needed + let finalMessage = message; + if (message.compressed) { + try { + const decompressedJson = await decompressString(message.templateJson); + console.log( + `[Collaboration] Decompressed template: ${message.templateJson.length} -> ${decompressedJson.length} bytes`, + ); + finalMessage = { + ...message, + templateJson: decompressedJson, + compressed: false, + }; + } catch (e) { + console.error("[Collaboration] Decompression failed:", e); + return; // Don't notify listeners with corrupted data + } + } + + this.templateSyncListeners.forEach((cb) => { + try { + cb(finalMessage); + } catch (e) { + console.error("[Collaboration] Listener error:", e); + } + }); + }); + // Reconnection handling this.connection.onreconnecting(() => { console.log("[Collaboration] Reconnecting..."); diff --git a/frontend/src/services/signalr.ts b/frontend/src/services/signalr.ts index 967bbe8..372c0ff 100644 --- a/frontend/src/services/signalr.ts +++ b/frontend/src/services/signalr.ts @@ -1,6 +1,17 @@ import * as signalR from "@microsoft/signalr"; -const API_URL = "http://localhost:5000"; +// Usa variabile ambiente o fallback a window.location per accesso esterno +const getApiUrl = (): string => { + // Prima controlla la variabile ambiente (deve essere non-vuota) + const envUrl = import.meta.env.VITE_API_URL; + if (envUrl && envUrl.trim() !== "") { + return envUrl; + } + // Fallback: usa lo stesso host del frontend ma porta 5000 + const protocol = window.location.protocol; + const hostname = window.location.hostname; + return `${protocol}//${hostname}:5000`; +}; class SignalRService { private connection: signalR.HubConnection | null = null; @@ -24,8 +35,10 @@ class SignalRService { this.isConnecting = true; try { + const apiUrl = getApiUrl(); // Calcola URL al momento della connessione + console.log(`[SignalR Data] Connecting to ${apiUrl}/hubs/data`); this.connection = new signalR.HubConnectionBuilder() - .withUrl(`${API_URL}/hubs/data`) + .withUrl(`${apiUrl}/hubs/data`) .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) .configureLogging(signalR.LogLevel.Information) .build(); diff --git a/src/Apollinare.API/Hubs/CollaborationHub.cs b/src/Apollinare.API/Hubs/CollaborationHub.cs index a6643be..bdbab25 100644 --- a/src/Apollinare.API/Hubs/CollaborationHub.cs +++ b/src/Apollinare.API/Hubs/CollaborationHub.cs @@ -309,6 +309,30 @@ public class CollaborationHub : Hub Console.WriteLine($"[Collaboration] DataSaved sent to others in room {roomKey}"); } + /// + /// Sincronizza l'intero template con tutti gli altri collaboratori nella room + /// Usato per sync efficienti - invia il template completo invece di richiedere reload dal server + /// Supporta compressione opzionale per template grandi + /// + public async Task BroadcastTemplateSync(string roomKey, string templateJson, int version, bool compressed = false) + { + var senderId = Context.ConnectionId; + Console.WriteLine($"[Collaboration] BroadcastTemplateSync from {senderId} for room {roomKey}, version {version}, size: {templateJson.Length} bytes, compressed: {compressed}"); + + UpdateUserActivity(senderId); + + await Clients.OthersInGroup(roomKey).SendAsync("TemplateSync", new TemplateSyncMessage + { + TemplateJson = templateJson, + SenderConnectionId = senderId, + Version = version, + Compressed = compressed, + Timestamp = DateTime.UtcNow + }); + + Console.WriteLine($"[Collaboration] TemplateSync broadcast complete for room {roomKey}"); + } + #endregion #region Lifecycle @@ -517,6 +541,19 @@ public class DataSavedMessage public DateTime Timestamp { get; set; } } +/// +/// Message for efficient template sync - sends full template JSON (compressed optional) +/// Used for initial sync and full resync requests +/// +public class TemplateSyncMessage +{ + public string TemplateJson { get; set; } = string.Empty; + public string SenderConnectionId { get; set; } = string.Empty; + public int Version { get; set; } // Incrementing version number for conflict detection + public bool Compressed { get; set; } // Whether TemplateJson is base64-encoded gzip + public DateTime Timestamp { get; set; } +} + public class UserLeftMessage { public string ConnectionId { get; set; } = string.Empty; diff --git a/src/Apollinare.API/Hubs/DataHub.cs b/src/Apollinare.API/Hubs/DataHub.cs index 4142e12..b1cf357 100644 --- a/src/Apollinare.API/Hubs/DataHub.cs +++ b/src/Apollinare.API/Hubs/DataHub.cs @@ -12,19 +12,50 @@ public class DataHub : Hub /// public async Task NotifyDataChanged(string entityType, string action, object? data = null) { - await Clients.Others.SendAsync("DataChanged", entityType, action, data); + try + { + await Clients.Others.SendAsync("DataChanged", entityType, action, data); + } + catch (Exception ex) + { + Console.WriteLine($"[DataHub] Error in NotifyDataChanged: {ex.Message}"); + } } public override async Task OnConnectedAsync() { - await base.OnConnectedAsync(); - Console.WriteLine($"Client connected: {Context.ConnectionId}"); + try + { + Console.WriteLine($"[DataHub] Client connecting: {Context.ConnectionId}"); + await base.OnConnectedAsync(); + Console.WriteLine($"[DataHub] Client connected: {Context.ConnectionId}"); + } + catch (Exception ex) + { + Console.WriteLine($"[DataHub] Error in OnConnectedAsync: {ex.Message}"); + throw; + } } public override async Task OnDisconnectedAsync(Exception? exception) { - await base.OnDisconnectedAsync(exception); - Console.WriteLine($"Client disconnected: {Context.ConnectionId}"); + try + { + if (exception != null) + { + Console.WriteLine($"[DataHub] Client disconnected with error: {Context.ConnectionId} - {exception.Message}"); + } + else + { + Console.WriteLine($"[DataHub] Client disconnected: {Context.ConnectionId}"); + } + await base.OnDisconnectedAsync(exception); + } + catch (Exception ex) + { + Console.WriteLine($"[DataHub] Error in OnDisconnectedAsync: {ex.Message}"); + throw; + } } } diff --git a/src/Apollinare.API/Program.cs b/src/Apollinare.API/Program.cs index 4823dea..e9497a6 100644 --- a/src/Apollinare.API/Program.cs +++ b/src/Apollinare.API/Program.cs @@ -19,20 +19,23 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); -// SignalR -builder.Services.AddSignalR() +// SignalR - with increased message size for template sync (default is 32KB) +builder.Services.AddSignalR(options => + { + options.MaximumReceiveMessageSize = 1024 * 1024; // 1MB max message size + }) .AddJsonProtocol(options => { options.PayloadSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; options.PayloadSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; }); -// CORS - Allow credentials for SignalR +// CORS - Allow credentials for SignalR (accepts any origin for external access) builder.Services.AddCors(options => { options.AddPolicy("AllowFrontend", policy => { - policy.SetIsOriginAllowed(origin => new Uri(origin).Host == "localhost") + policy.SetIsOriginAllowed(_ => true) // Permette qualsiasi origine .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); diff --git a/src/Apollinare.API/apollinare.db-shm b/src/Apollinare.API/apollinare.db-shm index b4a1cce85f33e59387d1772197de05d175d5beb4..4c022c7e49523cbe9fc8dbd0d7d5cb577aa60ce5 100644 GIT binary patch delta 390 zcmZo@U}|V!s+V}A%K!q55GGM zGzt#>BLS%3#Cp!nUzp8oHcw!g#=^+AxshchBO@b_3T9;F2U35T7zH4dAcPWvP{I&O z1VV{IC@~-f)XoH?fZD}@lqMr1GmyH>$S47%fQCsz1X&=0QVb-x^HKl3JkLFK#;fF?Prs${ zQ%iF8J2RnN$#<>!-iEX#?Z~a@PwpUhk`AOJNhf!aPNXxrn{*)=3E5CdXB42S_SAO^&M7!U(uKn#ch zF(3xSfEW-1Vn7Ut0Wly3#DEwO17bi7hygJm2E>3E5CdXB42S_SAO^&M7!U(uKn#ch zF(3x~HgG-v5Bi6GpR(~l7$*+Q!XX2$-WrpzTVu{>&OcbGv42_A=R}EIJdL-p$7hZk zIDO$GpB3_Dv4P5*wJ{q%h>r8fKr_xA`S?7#PvEXng;Qo;#Nog-kSDutAePgAHn`r^ z#D`#r!RnV=bJGapDS0}Tv(m^66WJqn-wVDy*#&7OvYD=)jG>=!4vpGX8>b*U%^cw zuj4mk^6CqE5%;uA!Poi%D0fc87;I6lwz;qdcjPR!&u%I(uLa6qwIXIU#u#4eoyjOksM8@%7bKgQ(M7qk-huFu3leF2m^ a*W*U^r8ucvf36D~atr*s=lY?3Jnvs{0s095 diff --git a/src/Apollinare.API/apollinare.db-wal b/src/Apollinare.API/apollinare.db-wal index 9626a9317baa688f5806e34b5e9e1753dfdf41c5..803f73373f8bad50315c9f64369459f10098627d 100644 GIT binary patch delta 1904 zcmd6oe@sd){*n8#R&`zXTCbe#_5$p<*b5K-ZES z1v*ne+MO{hGg0Tn5bI^Ng0Txam@P6`lde`G*&2qBv(ctKFX`?5zkj;h-Ja)jzUTWq z@B5zfoY~)Byff{iYx}JGy&`{J<>eLN#eWq+`-XO3NEmwG{@feSIJ?>anMCf>*O7D} z+D@GT?dCP6NaqhO+h$WF+tqG9;~((k<}V|ZCtPzB;@^1|)OB7LzWSJmCr&wn(hM%9QwrrzSur$^g$Bk+sP8T)Z?s;q7{*zU}wz0bkH;OBQ zBtsMhOg>1Q_;}-|z|vXe25wXhai*v__Il-#Z)4_afMu|+0=bbxhh)1bN?4D~9Wy7U zfMv31D>q7lc!#LTYQ7S^d@R@oY&$zV?*~St1QNas&nxu6{rQC2R~Q0l2eVCEz^Dx3 zY*AB#Z--AGj^{>Mtl%0q%H-|kh@zLr*q!8&@d;qr%ss@7_+I3Sn)&GUard&nnFp4` zq~67j_CmrR3QtF)VWcDM@-7RYTz38xH+mJ~d{I+8Jd^MGE}U;k9(&lvjmqL7DG)^? zpFMl-95fwWG{w<|2?9!mru=|8NA{GRKzORaicnj`Js3; z%^4RD_8;?E380J@h(Fa1QIqm$A>`KJ-7a8u_QF$qkFqKt;qQ{C zTKu8X=?#@>s{lEedypHw#xIK+-0Hj@Ic>Yane!gCeAIJu$S0go2`flfRYG76>I9|` z3{n{>5)2<0ugJiPW?*5hx@WU5c*qmxIr3PcJ<`jaB`UX$b~Y z&g66EE)yGwvt08ISO0fIJCEuTT^!~VVGR=%|Fe1wi-R>|iio1>GKEq8_{;DgW_AN? c$Xic7f9F4F{Jzzd6wddJbzg!pV=i<24Zy-mwEzGB delta 14479 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