diff --git a/app/src/scripts/load.js b/app/src/scripts/load.js index c4ff3fc..e88c077 100644 --- a/app/src/scripts/load.js +++ b/app/src/scripts/load.js @@ -65,6 +65,109 @@ function hideMessage() { }, 500); } +// ========== OFFLINE DETECTION & AUTO-RESTORE (Solix Approach) ========== + +// Listen for online/offline events +window.addEventListener("online", () => { + console.log("🟢 Back online - syncing queued changes..."); + const indicator = document.getElementById("persistenceStatus"); + if (indicator) { + indicator.textContent = "Syncing..."; + indicator.className = "status-indicator status-loading"; + } + // Sync queued changes + syncQueuedChanges(); +}); + +window.addEventListener("offline", () => { + console.log("🔴 Going offline - using cached data..."); + const indicator = document.getElementById("persistenceStatus"); + if (indicator) { + indicator.textContent = "Offline mode"; + indicator.className = "status-indicator status-offline"; + } +}); + +// Function to sync queued changes when back online +async function syncQueuedChanges() { + try { + if (typeof dashboardDB === "undefined" || !dashboardDB) return; + + const transaction = dashboardDB.transaction(["syncQueue"], "readonly"); + const store = transaction.objectStore("syncQueue"); + const request = store.getAll(); + + request.onsuccess = async (event) => { + const queuedChanges = event.target.result; + + if (queuedChanges.length > 0) { + console.log(`Syncing ${queuedChanges.length} queued changes...`); + + for (const change of queuedChanges) { + try { + // Process each queued change (e.g., retry failed fetches) + if (change.type === "fresh_data_fetch") { + await backgroundFetchFreshData(change.data.source, change.data.inputValues); + } + // Remove from queue after processing + const deleteTransaction = dashboardDB.transaction(["syncQueue"], "readwrite"); + const deleteStore = deleteTransaction.objectStore("syncQueue"); + deleteStore.delete(change.id); + } catch (err) { + console.log("Failed to sync change:", err); + } + } + + const indicator = document.getElementById("persistenceStatus"); + if (indicator) { + indicator.textContent = "Synced ✓"; + indicator.className = "status-indicator status-success"; + } + } + }; + } catch (err) { + console.log("Sync failed:", err); + } +} + +// Auto-restore dashboard on page load +document.addEventListener("DOMContentLoaded", async () => { + await loadComponents(); + + // Initialize IndexedDB and check for saved dashboard + setTimeout(async () => { + try { + if (typeof initIndexedDB === "function") { + await initIndexedDB(); + + if (typeof loadLatestDashboardFromIndexedDB === "function") { + const savedDashboard = await loadLatestDashboardFromIndexedDB(); + + if (savedDashboard) { + const age = new Date().getTime() - savedDashboard.timestamp; + const ageMinutes = Math.floor(age / (1000 * 60)); + + // Show auto-restore option + const restoreMsg = document.createElement("div"); + restoreMsg.id = "restorePrompt"; + restoreMsg.className = "restore-prompt"; + restoreMsg.innerHTML = ` + 📂 Restore dashboard from ${ageMinutes}min ago? + + + `; + document.body.insertBefore(restoreMsg, document.body.firstChild); + } + } + } + } catch (err) { + console.log("Auto-restore check failed:", err); + } + }, 100); +}); + +// ========== END OFFLINE DETECTION & AUTO-RESTORE ========== + if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker diff --git a/app/src/scripts/script.js b/app/src/scripts/script.js index 5f7e4df..9fc9159 100644 --- a/app/src/scripts/script.js +++ b/app/src/scripts/script.js @@ -11,6 +11,146 @@ if (!sourceSelect || !loadBtn || !container) { showMessage("Error: Required UI elements are missing."); } +// ========== INDEXEDDB PERSISTENCE (Solix Approach) ========== +let dashboardDB = null; +const DB_NAME = "OpenDotsDB"; +const DB_VERSION = 1; +const STORE_NAME = "dashboards"; +const SYNC_QUEUE_STORE = "syncQueue"; + +// Initialize IndexedDB +async function initIndexedDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + dashboardDB = request.result; + resolve(dashboardDB); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // Create dashboards store + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true }); + store.createIndex("timestamp", "timestamp", { unique: false }); + store.createIndex("source", "source", { unique: false }); + } + + // Create sync queue store + if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) { + db.createObjectStore(SYNC_QUEUE_STORE, { keyPath: "id", autoIncrement: true }); + } + }; + }); +} + +// Save dashboard state to IndexedDB +async function saveDashboardToIndexedDB(dashboardData) { + if (!dashboardDB) await initIndexedDB(); + + return new Promise((resolve, reject) => { + const transaction = dashboardDB.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + + const dashboardObject = { + data: dashboardData.data, + chartConfigs: dashboardData.chartConfigs || [], + slicerValue: dashboardData.slicerValue || "all", + sourceSelection: { + source: dashboardData.source, + inputs: dashboardData.inputs + }, + timestamp: new Date().getTime(), + status: "synced" + }; + + const request = store.add(dashboardObject); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + updateStatusIndicator("Saved ✓", "success"); + resolve(request.result); + }; + }); +} + +// Load latest dashboard from IndexedDB +async function loadLatestDashboardFromIndexedDB() { + if (!dashboardDB) await initIndexedDB(); + + return new Promise((resolve, reject) => { + const transaction = dashboardDB.transaction([STORE_NAME], "readonly"); + const store = transaction.objectStore(STORE_NAME); + const index = store.index("timestamp"); + + const request = index.openCursor(null, "prev"); // Get latest first + let latestDashboard = null; + + request.onerror = () => reject(request.error); + request.onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + latestDashboard = cursor.value; + cursor.continue(); + } else { + resolve(latestDashboard); + } + }; + }); +} + +// Clear all dashboards from IndexedDB +async function clearAllDashboards() { + if (!dashboardDB) await initIndexedDB(); + + return new Promise((resolve, reject) => { + const transaction = dashboardDB.transaction([STORE_NAME], "readwrite"); + const store = transaction.objectStore(STORE_NAME); + const request = store.clear(); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + updateStatusIndicator("Cleared ✓", "info"); + resolve(); + }; + }); +} + +// Queue change for sync (when offline) +async function queueSyncChange(changeData) { + if (!dashboardDB) await initIndexedDB(); + + return new Promise((resolve, reject) => { + const transaction = dashboardDB.transaction([SYNC_QUEUE_STORE], "readwrite"); + const store = transaction.objectStore(SYNC_QUEUE_STORE); + + const syncItem = { + type: changeData.type, // "fetch", "config_update", etc. + timestamp: new Date().getTime(), + data: changeData.data, + status: "pending" + }; + + const request = store.add(syncItem); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); +} + +// Update status indicator UI +function updateStatusIndicator(message, type = "info") { + const indicator = document.getElementById("persistenceStatus"); + if (indicator) { + indicator.textContent = message; + indicator.className = `status-indicator status-${type}`; + } +} + +// ========== END INDEXEDDB PERSISTENCE ========== + // Input configurations const inputsConfig = { mqtt: [ @@ -189,7 +329,26 @@ async function loadData() { ) : null; - if (data) renderData(data); + if (data) { + renderData(data); + + // Save dashboard state to IndexedDB (Solix approach) + const inputValues = {}; + for (const input of inputsConfig[source] || []) { + inputValues[input.id] = document.getElementById(input.id)?.value || ""; + } + + await saveDashboardToIndexedDB({ + data: data, + chartConfigs: charts.map(c => ({ type: c.config.type, data: c.data, options: c.options })), + slicerValue: document.querySelector('input[name="slicer"]:checked')?.value || "all", + source: source, + inputs: inputValues + }); + + // Start background fetch for fresh data + backgroundFetchFreshData(source, inputValues); + } } catch (err) { resetUI(); showMessage("Error: " + err.message); @@ -594,6 +753,148 @@ function openChartModal(originalCanvas) { } } +// ========== PERSISTENCE CONTROL FUNCTIONS ========== + +// Background fetch for fresh data (Solix approach - updates cache) +async function backgroundFetchFreshData(source, inputValues) { + // Fetch silently in background without showing loader + try { + updateStatusIndicator("Syncing...", "loading"); + + let freshData = null; + if (source === "thingspeak") { + freshData = await fetchThingSpeak(inputValues.channelId); + } else if (source === "adafruit") { + freshData = await fetchAdafruit(inputValues.username, inputValues.key, inputValues.feed); + } else if (source === "blynk") { + freshData = await fetchBlynk(inputValues.auth, inputValues.pin); + } else if (source === "grafana") { + freshData = await fetchGrafana(inputValues.url, inputValues.token, inputValues.query); + } + + if (freshData && freshData !== data) { + // Data updated, save new version and re-render + data = freshData; + renderData(data); // Re-render charts and table with fresh data + await saveDashboardToIndexedDB({ + data: data, + chartConfigs: charts.map(c => ({ type: c.config.type, data: c.data, options: c.options })), + slicerValue: document.querySelector('input[name="slicer"]:checked')?.value || "all", + source: source, + inputs: inputValues + }); + updateStatusIndicator("Updated " + new Date().toLocaleTimeString(), "success"); + } else { + updateStatusIndicator("Up to date ✓", "success"); + } + } catch (err) { + console.log("Background sync failed (will retry later):", err); + // Queue this for later sync if offline + if (!navigator.onLine) { + await queueSyncChange({ + type: "fresh_data_fetch", + data: { source, inputValues } + }); + updateStatusIndicator("Offline - will sync later", "offline"); + } + } +} + +// Manually save current dashboard state +async function saveDashboardState() { + if (!data) { + showMessage("No data to save. Fetch data first."); + return; + } + + try { + const source = document.getElementById("sourceSelect").value; + const inputValues = {}; + for (const input of inputsConfig[source] || []) { + inputValues[input.id] = document.getElementById(input.id)?.value || ""; + } + + await saveDashboardToIndexedDB({ + data: data, + chartConfigs: charts.map(c => ({ type: c.config.type, data: c.data, options: c.options })), + slicerValue: document.querySelector('input[name="slicer"]:checked')?.value || "all", + source: source, + inputs: inputValues + }); + + showMessage("Dashboard saved successfully! ✓"); + } catch (err) { + showMessage("Error saving dashboard: " + err.message); + } +} + +// Manually load latest saved dashboard +async function loadDashboardState() { + try { + const savedDashboard = await loadLatestDashboardFromIndexedDB(); + + if (!savedDashboard) { + showMessage("No saved dashboard found."); + return; + } + + // Check if data is recent + const age = new Date().getTime() - savedDashboard.timestamp; + const ageHours = Math.floor(age / (1000 * 60 * 60)); + + if (ageHours > 24) { + const confirmLoad = confirm(`Saved data is ${ageHours} hours old. Load it anyway?`); + if (!confirmLoad) return; + } + + // Restore data + data = savedDashboard.data; + const source = savedDashboard.sourceSelection.source; + + // Set source and input values + document.getElementById("sourceSelect").value = source; + resetUI(); + renderInputs(); + + for (const [key, value] of Object.entries(savedDashboard.sourceSelection.inputs)) { + const input = document.getElementById(key); + if (input) input.value = value; + } + + // Render data and restore slicer + renderData(data); + const slicerRadio = document.querySelector(`input[name="slicer"][value="${savedDashboard.slicerValue}"]`); + if (slicerRadio) slicerRadio.checked = true; + + // Show when data was saved + const savedTime = new Date(savedDashboard.timestamp).toLocaleString(); + updateStatusIndicator(`Loaded (${savedTime})`, "success"); + showMessage(`Dashboard loaded from ${savedTime} ✓`); + + // Start background sync for fresh data + backgroundFetchFreshData(source, savedDashboard.sourceSelection.inputs); + } catch (err) { + showMessage("Error loading dashboard: " + err.message); + } +} + +// Clear all saved dashboards +async function clearDashboardState() { + const confirmed = confirm("Delete all saved dashboards? This cannot be undone."); + if (!confirmed) return; + + try { + await clearAllDashboards(); + resetUI(); + document.getElementById("sourceSelect").value = "DataSource"; + showMessage("All saved dashboards cleared. ✓"); + } catch (err) { + showMessage("Error clearing dashboards: " + err.message); + } +} + +// ========== END PERSISTENCE CONTROL FUNCTIONS ========== + async function ask() { const queryInput = document.getElementById("query"); const query = queryInput.value.trim(); diff --git a/app/src/styles/style.css b/app/src/styles/style.css index 7a24db5..fea4632 100644 --- a/app/src/styles/style.css +++ b/app/src/styles/style.css @@ -364,6 +364,128 @@ input:-moz-autofill { color: rgb(1, 185, 185); } +/* ========== PERSISTENCE CONTROLS (Issue #14 - Solix Approach) ========== */ +.persistence-controls { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +.persist-btn { + padding: 6px 12px; + border-radius: 5px; + cursor: pointer; + font-size: 13px; + background: var(--bg-color); + color: #eee; + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.persist-btn:hover { + background: #2a2a2a; + border-color: rgb(1, 185, 185); +} + +.persist-btn:active { + transform: scale(0.95); +} + +/* Status Indicator */ +.status-indicator { + margin-top: 8px; + padding: 6px 10px; + border-radius: 4px; + font-size: 12px; + text-align: center; + display: none; +} + +.status-indicator:not(:empty) { + display: block; +} + +.status-success { + background-color: #1a3a1a; + color: #00ff00; + border: 1px solid #00aa00; +} + +.status-loading { + background-color: #1a1a3a; + color: #00ffff; + border: 1px solid #0088ff; + animation: pulse 1s infinite; +} + +.status-offline { + background-color: #3a1a1a; + color: #ffaa00; + border: 1px solid #ff6600; +} + +.status-info { + background-color: #1a2a3a; + color: #88ddff; + border: 1px solid #0088dd; +} + +.status-error { + background-color: #3a1a1a; + color: #ff6666; + border: 1px solid #ff0000; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* Auto-Restore Prompt */ +.restore-prompt { + position: fixed; + top: 70px; + left: 50%; + transform: translateX(-50%); + background-color: #1a2a1a; + border: 1px solid #00aa00; + border-radius: 5px; + padding: 12px 20px; + display: flex; + gap: 10px; + align-items: center; + z-index: 100; + color: #00ff00; + font-size: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); +} + +.restore-yes, +.restore-no { + padding: 5px 12px; + border-radius: 3px; + border: 1px solid #00aa00; + background-color: #0a1a0a; + color: #00ff00; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; +} + +.restore-yes:hover { + background-color: #00aa00; + color: #000000; +} + +.restore-no:hover { + background-color: #ff6666; + border-color: #ff0000; + color: #ffffff; +} + +/* ========== END PERSISTENCE CONTROLS ========== */ + #chartsContainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); @@ -630,4 +752,24 @@ footer { #response-container { height: calc(100vh - 274px); } + + /* Mobile responsive persistence controls */ + .persistence-controls { + flex-direction: column; + } + + .persist-btn { + width: 100%; + } + + .restore-prompt { + top: 60px; + width: calc(100vw - 30px); + flex-direction: column; + text-align: center; + } + + .status-indicator { + width: 100%; + } } \ No newline at end of file diff --git a/index.html b/index.html index 2b5c4bd..019ff79 100644 --- a/index.html +++ b/index.html @@ -58,6 +58,14 @@

Configure

+ +
+ + + +
+ +