UNPKG

@oriolrius/grafana-business-text

Version:

Data-driven text with Markdown and Handlebars support for Grafana

129 lines 47.5 kB
{ "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, "id": null, "links": [], "liveNow": false, "panels": [ { "id": 1, "type": "volkovlabs-text-panel", "title": "SQLite based device management", "gridPos": { "x": 0, "y": 0, "h": 20, "w": 24 }, "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "pluginVersion": "6.0.0", "targets": [ { "datasource": { "type": "marcusolsson-static-datasource", "uid": "U0HP2Rv4z" }, "frame": { "fields": [ { "config": {}, "name": "static", "type": "string", "values": [ "data" ] } ], "meta": {} }, "refId": "A" } ], "datasource": { "type": "marcusolsson-static-datasource", "uid": "U0HP2Rv4z" }, "options": { "renderMode": "data", "editors": [ "content", "afterRender", "styles" ], "editor": { "language": "html", "format": "auto" }, "wrap": true, "contentPartials": [], "content": "<!-- SQLite Device Management Dashboard -->\n<div style=\"padding: 20px; font-family: 'Inter', sans-serif;\">\n <div style=\"margin-bottom: 20px; padding: 15px; background: var(--background-secondary); border-radius: 8px; border-left: 4px solid var(--primary-color);\">\n <h2 style=\"color: var(--text-primary); margin: 0 0 10px 0; display: flex; align-items: center;\">\n <i class=\"fas fa-database\" style=\"margin-right: 12px; color: var(--primary-color);\"></i>\n SQLite Device Management System\n </h2>\n <p style=\"color: var(--text-secondary); margin: 5px 0; font-size: 14px;\">\n Advanced device management with inline editing, CRUD operations, and keyboard navigation.\n <strong>Adapted for SQLite compatibility.</strong>\n </p>\n </div>\n \n <div id=\"devices-table\" style=\"\n background: var(--background-secondary);\n border: 1px solid var(--border-medium);\n border-radius: 8px;\n min-height: 400px;\n overflow: hidden;\n \">\n <div style=\"text-align: center; padding: 60px 20px; color: var(--text-secondary);\">\n <i class=\"fas fa-spinner fa-spin\" style=\"font-size: 32px; margin-bottom: 15px; color: var(--primary-color);\"></i>\n <div style=\"font-size: 16px; margin-bottom: 10px;\">Loading SQLite device data...</div>\n <div style=\"font-size: 14px; opacity: 0.8;\">Click the button below to load and manage devices</div>\n </div>\n </div>\n \n <div style=\"margin-top: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between;\">\n <button id=\"load-devices\" style=\"\n background: linear-gradient(135deg, var(--primary-color), var(--primary-color)dd);\n color: var(--primary-text-color);\n border: none;\n padding: 12px 24px;\n border-radius: 8px;\n font-weight: 600;\n cursor: pointer;\n transition: all 0.3s ease;\n font-size: 14px;\n box-shadow: 0 2px 8px rgba(0,0,0,0.1);\n display: flex;\n align-items: center;\n gap: 8px;\n \" onmouseover=\"this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.2)'\" onmouseout=\"this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 8px rgba(0,0,0,0.1)'\">\n <i class=\"fas fa-sync-alt\"></i>\n Load SQLite Devices\n </button>\n \n <div style=\"font-size: 12px; color: var(--text-secondary);\">\n <i class=\"fas fa-info-circle\" style=\"margin-right: 5px;\"></i>\n Database: <code style=\"background: var(--background-canvas); padding: 2px 6px; border-radius: 3px;\">./tmp/devices.db</code>\n </div>\n </div>\n \n <div style=\"margin-top: 25px; padding: 20px; background: var(--background-canvas); border-radius: 8px; border: 1px solid var(--border-weak);\">\n <h3 style=\"color: var(--text-primary); margin: 0 0 15px 0; font-size: 16px; display: flex; align-items: center;\">\n <i class=\"fas fa-keyboard\" style=\"margin-right: 8px; color: var(--success-color);\"></i>\n Advanced Features\n </h3>\n <div style=\"display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; font-size: 13px;\">\n <div>\n <strong style=\"color: var(--text-primary);\">✏️ Inline Editing:</strong>\n <ul style=\"margin: 5px 0; padding-left: 20px; color: var(--text-secondary);\">\n <li>Click edit icon or any cell to modify</li>\n <li>Real-time validation with visual feedback</li>\n <li>Timestamp format detection</li>\n </ul>\n </div>\n <div>\n <strong style=\"color: var(--text-primary);\">⌨️ Keyboard Navigation:</strong>\n <ul style=\"margin: 5px 0; padding-left: 20px; color: var(--text-secondary);\">\n <li><kbd>Tab</kbd> / <kbd>Shift+Tab</kbd> - Navigate cells</li>\n <li><kbd>Enter</kbd> - Next cell or save row</li>\n <li><kbd>Ctrl+S</kbd> - Save current row</li>\n <li><kbd>Esc</kbd> - Cancel editing</li>\n </ul>\n </div>\n <div>\n <strong style=\"color: var(--text-primary);\">💾 CRUD Operations:</strong>\n <ul style=\"margin: 5px 0; padding-left: 20px; color: var(--text-secondary);\">\n <li>Create, Read, Update, Delete records</li>\n <li>Individual row save/cancel operations</li>\n <li>Confirmation dialogs for destructive actions</li>\n </ul>\n </div>\n <div>\n <strong style=\"color: var(--text-primary);\">🎨 UI Features:</strong>\n <ul style=\"margin: 5px 0; padding-left: 20px; color: var(--text-secondary);\">\n <li>Frozen columns (hostname, ip_address)</li>\n <li>Grafana theme integration</li>\n <li>Smooth animations and transitions</li>\n <li>Responsive table layout</li>\n </ul>\n </div>\n </div>\n </div>\n \n <div style=\"margin-top: 20px; padding: 15px; background: var(--warning-color)20; border-radius: 6px; border-left: 4px solid var(--warning-color); font-size: 13px;\">\n <strong style=\"color: var(--warning-text);\">🔧 SQLite Specific Notes:</strong>\n <ul style=\"margin: 8px 0; padding-left: 20px; color: var(--warning-text);\">\n <li>Timestamps stored as Unix epoch integers</li>\n <li>Boolean values stored as 1/0 (SQLite standard)</li>\n <li>String escaping uses SQLite double-quote syntax</li>\n <li>Auto-detection of ID fields (id, *_id, uid, etc.)</li>\n </ul>\n </div>\n</div>\n\n<!-- Font Awesome CSS -->\n<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css\" integrity=\"sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n\n<style>\nkbd {\n background: var(--background-canvas);\n border: 1px solid var(--border-medium);\n border-radius: 3px;\n box-shadow: 0 1px 1px rgba(0,0,0,0.2), 0 2px 0 0 rgba(255,255,255,0.7) inset;\n color: var(--text-primary);\n display: inline-block;\n font-size: 0.85em;\n font-weight: 700;\n line-height: 1;\n padding: 2px 4px;\n white-space: nowrap;\n}\n</style>", "defaultContent": "The query didn't return any results.", "helpersRemoteUrl": "", "helpers": "", "afterRenderRemoteUrl": "", "afterRender": "// SQLite Device Management - Learning Panel Edit\n// Advanced device management with inline editing, CRUD operations, and keyboard navigation\n// Adapted from PostgreSQL version for SQLite compatibility\n\nlet currentDataSourceUid = 'sqlite-demo'; // SQLite datasource UID\nlet editingCell = null;\nlet originalValues = {};\nlet tableStructure = null; // Store table structure info\nlet currentFocusedInput = null; // Track currently focused input for keyboard navigation\n\nasync function loadDevices() {\n try {\n const result = await context.dataSource.sql(currentDataSourceUid, 'SELECT * FROM devices ORDER BY id');\n const rows = context.dataSource.utils.toObjects(result);\n\n if (rows.length === 0) {\n document.getElementById('devices-table').innerHTML = '<p style=\"color: var(--text-secondary);\">No devices found</p>';\n return;\n }\n\n // Store table structure information for better field handling\n const keys = Object.keys(rows[0]);\n tableStructure = {\n fields: keys,\n idField: detectIdField(keys), // Better ID field detection\n editableFields: keys.filter(key => !isIdField(key, keys)) // Filter out ID fields\n };\n\n // Get Grafana theme for styling\n const theme = context.grafana.theme;\n\n // Create scrollable table container with frozen columns\n let html = `\n <div style=\"\n position: relative; \n overflow-x: auto; \n overflow-y: visible;\n border: 1px solid ${theme.colors.border.weak};\n max-width: 100%;\n \">\n <table style=\"\n width: max-content;\n min-width: 100%;\n border-collapse: collapse;\n background: ${theme.colors.background.primary};\n color: ${theme.colors.text.primary};\n font-family: ${theme.typography.fontFamily};\n font-size: ${theme.typography.body.fontSize};\n table-layout: fixed;\n \">\n <thead>\n <tr style=\"background: ${theme.colors.primary.main};\">\n <!-- Actions column (frozen left) -->\n <th style=\"\n position: sticky;\n left: 0;\n z-index: 20;\n width: 80px;\n padding: ${theme.spacing(1, 2)};\n border: 1px solid ${theme.colors.border.medium};\n font-weight: ${theme.typography.fontWeightMedium};\n color: ${theme.colors.primary.contrastText};\n text-align: center;\n background: ${theme.colors.primary.main};\n background-clip: padding-box;\n box-shadow: 2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${theme.colors.primary.main};\n border-right: 3px solid ${theme.colors.border.strong};\n \">Actions</th>\n \n <!-- Frozen hostname column -->\n <th style=\"\n position: sticky;\n left: 80px;\n z-index: 19;\n width: 150px;\n padding: ${theme.spacing(1, 2)};\n border: 1px solid ${theme.colors.border.medium};\n font-weight: ${theme.typography.fontWeightMedium};\n color: ${theme.colors.primary.contrastText};\n text-align: left;\n background: ${theme.colors.primary.main};\n background-clip: padding-box;\n box-shadow: 2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${theme.colors.primary.main};\n border-right: 3px solid ${theme.colors.border.strong};\n \">hostname</th>\n \n <!-- Frozen ip_address column -->\n <th style=\"\n position: sticky;\n left: 230px;\n z-index: 18;\n width: 140px;\n padding: ${theme.spacing(1, 2)};\n border: 1px solid ${theme.colors.border.medium};\n font-weight: ${theme.typography.fontWeightMedium};\n color: ${theme.colors.primary.contrastText};\n text-align: left;\n background: ${theme.colors.primary.main};\n background-clip: padding-box;\n box-shadow: 2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${theme.colors.primary.main};\n border-right: 3px solid ${theme.colors.border.strong};\n \">ip_address</th>`;\n\n // Add remaining headers (scrollable)\n tableStructure.fields.forEach(key => {\n const isId = key === tableStructure.idField;\n const isFrozen = key === 'hostname' || key === 'ip_address';\n\n // Skip ID column and frozen columns (already added)\n if (isId || isFrozen) return;\n\n html += `<th style=\"\n min-width: 120px;\n padding: ${theme.spacing(1, 2)};\n border: 1px solid ${theme.colors.border.medium};\n font-weight: ${theme.typography.fontWeightMedium};\n color: ${theme.colors.primary.contrastText};\n text-align: left;\n background: ${theme.colors.primary.main};\n \">${key}</th>`;\n });\n\n html += `\n </tr>\n </thead>\n <tbody>`;\n\n // Add rows with Font Awesome icons and frozen columns\n rows.forEach((row, index) => {\n // Alternate row colors for better readability\n const rowBg = index % 2 === 0 ? theme.colors.background.primary : theme.colors.background.secondary;\n\n html += `<tr data-row-index=\"${index}\" style=\"\n background: ${rowBg};\n \" onmouseover=\"\n this.style.background='${theme.colors.action.hover}';\n // Update frozen column backgrounds on hover\n const actionCell = this.children[0];\n const hostnameCell = this.children[1];\n const ipCell = this.children[2];\n if (actionCell) actionCell.style.boxShadow = '2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${theme.colors.action.hover}, inset 3px 0 0 ${theme.colors.border.strong}';\n if (hostnameCell) hostnameCell.style.boxShadow = '2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${theme.colors.action.hover}, inset 3px 0 0 ${theme.colors.border.strong}';\n if (ipCell) ipCell.style.boxShadow = '2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${theme.colors.action.hover}, inset 3px 0 0 ${theme.colors.border.strong}';\n \" onmouseout=\"\n this.style.background='${rowBg}';\n // Restore frozen column backgrounds\n const actionCell = this.children[0];\n const hostnameCell = this.children[1];\n const ipCell = this.children[2];\n if (actionCell) actionCell.style.boxShadow = '2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${rowBg}, inset 3px 0 0 ${theme.colors.border.strong}';\n if (hostnameCell) hostnameCell.style.boxShadow = '2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${rowBg}, inset 3px 0 0 ${theme.colors.border.strong}';\n if (ipCell) ipCell.style.boxShadow = '2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${rowBg}, inset 3px 0 0 ${theme.colors.border.strong}';\n \">`;\n\n // Get ID value for operations\n const idValue = row[tableStructure.idField];\n\n // Actions column (frozen left)\n html += `<td style=\"\n position: sticky;\n left: 0;\n z-index: 15;\n width: 80px;\n padding: ${theme.spacing(1)};\n border: 1px solid transparent;\n background: ${rowBg};\n background-clip: padding-box;\n text-align: center;\n box-shadow: 2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${rowBg}, inset 3px 0 0 ${theme.colors.border.strong};\n border-right: 3px solid ${theme.colors.border.strong};\n \">\n \n <!-- Edit Icon -->\n <i class=\"edit-icon fas fa-pen\" \n data-device-id=\"${idValue}\" \n data-row-index=\"${index}\" \n style=\"\n color: ${theme.colors.info.main};\n cursor: pointer;\n font-size: 14px;\n margin-right: 8px;\n padding: 4px;\n border-radius: 3px;\n transition: all 0.2s ease;\n \"\n onmouseover=\"this.style.background='${theme.colors.info.main}15'; this.style.color='${theme.colors.info.shade}'\"\n onmouseout=\"this.style.background='transparent'; this.style.color='${theme.colors.info.main}'\"\n title=\"Edit row\"></i>\n \n <!-- Save Icon (hidden initially) -->\n <i class=\"save-icon fas fa-check\" \n data-device-id=\"${idValue}\" \n data-row-index=\"${index}\" \n style=\"\n color: ${theme.colors.success.main};\n cursor: pointer;\n font-size: 14px;\n margin-right: 4px;\n padding: 4px;\n border-radius: 3px;\n transition: all 0.2s ease;\n display: none;\n \"\n onmouseover=\"this.style.background='${theme.colors.success.main}15'; this.style.color='${theme.colors.success.shade}'\"\n onmouseout=\"this.style.background='transparent'; this.style.color='${theme.colors.success.main}'\"\n title=\"Save changes\"></i>\n \n <!-- Cancel Icon (hidden initially) -->\n <i class=\"cancel-icon fas fa-times\" \n data-device-id=\"${idValue}\" \n data-row-index=\"${index}\" \n style=\"\n color: ${theme.colors.warning.main};\n cursor: pointer;\n font-size: 14px;\n margin-right: 4px;\n padding: 4px;\n border-radius: 3px;\n transition: all 0.2s ease;\n display: none;\n \"\n onmouseover=\"this.style.background='${theme.colors.warning.main}15'; this.style.color='${theme.colors.warning.shade}'\"\n onmouseout=\"this.style.background='transparent'; this.style.color='${theme.colors.warning.main}'\"\n title=\"Cancel edit\"></i>\n \n <!-- Delete Icon -->\n <i class=\"delete-icon fas fa-trash\" \n data-device-id=\"${idValue}\" \n data-row-index=\"${index}\" \n style=\"\n color: ${theme.colors.error.main};\n cursor: pointer;\n font-size: 14px;\n padding: 4px;\n border-radius: 3px;\n transition: all 0.2s ease;\n \"\n onmouseover=\"this.style.background='${theme.colors.error.main}15'; this.style.color='${theme.colors.error.shade}'\"\n onmouseout=\"this.style.background='transparent'; this.style.color='${theme.colors.error.main}'\"\n title=\"Delete row\"></i>\n </td>`;\n\n // Frozen hostname column\n const hostnameValue = row['hostname'] || '';\n const hostnameKey = 'hostname';\n const hostnameCellId = `cell-${index}-hostname`;\n originalValues[hostnameCellId] = hostnameValue;\n\n html += `<td class=\"editable-cell\" \n data-field=\"${hostnameKey}\" \n data-cell-id=\"${hostnameCellId}\" \n data-row-index=\"${index}\"\n data-original-value=\"${hostnameValue}\"\n style=\"\n position: sticky;\n left: 80px;\n z-index: 14;\n width: 150px;\n padding: ${theme.spacing(1, 2)};\n border: 1px solid transparent;\n color: ${theme.colors.text.primary};\n cursor: pointer;\n background: ${rowBg};\n background-clip: padding-box;\n box-shadow: 2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${rowBg}, inset 3px 0 0 ${theme.colors.border.strong};\n border-right: 3px solid ${theme.colors.border.strong};\n \"\n title=\"Click to edit\">${hostnameValue}</td>`;\n\n // Frozen ip_address column\n const ipValue = row['ip_address'] || '';\n const ipKey = 'ip_address';\n const ipCellId = `cell-${index}-ip_address`;\n originalValues[ipCellId] = ipValue;\n\n html += `<td class=\"editable-cell\" \n data-field=\"${ipKey}\" \n data-cell-id=\"${ipCellId}\" \n data-row-index=\"${index}\"\n data-original-value=\"${ipValue}\"\n style=\"\n position: sticky;\n left: 230px;\n z-index: 13;\n width: 140px;\n padding: ${theme.spacing(1, 2)};\n border: 1px solid transparent;\n color: ${theme.colors.text.primary};\n cursor: pointer;\n background: ${rowBg};\n background-clip: padding-box;\n box-shadow: 2px 0 8px rgba(0,0,0,0.2), inset 0 0 0 1000px ${rowBg}, inset 3px 0 0 ${theme.colors.border.strong};\n border-right: 3px solid ${theme.colors.border.strong};\n \"\n title=\"Click to edit\">${ipValue}</td>`;\n\n // Add remaining scrollable columns\n tableStructure.fields.forEach((key, keyIndex) => {\n const isIdField = key === tableStructure.idField;\n const isFrozen = key === 'hostname' || key === 'ip_address';\n\n // Skip ID and frozen columns (already handled)\n if (isIdField || isFrozen) return;\n\n const cellId = `cell-${index}-${keyIndex}`;\n originalValues[cellId] = row[key];\n const isEditable = !isIdField;\n\n html += `<td class=\"${isEditable ? 'editable-cell' : 'id-cell'}\" \n data-field=\"${key}\" \n data-cell-id=\"${cellId}\" \n data-row-index=\"${index}\"\n data-field-index=\"${keyIndex}\"\n data-original-value=\"${row[key]}\"\n style=\"\n min-width: 120px;\n padding: ${theme.spacing(1, 2)};\n border: 1px solid ${theme.colors.border.weak};\n color: ${theme.colors.text.primary};\n cursor: ${isEditable ? 'pointer' : 'default'};\n position: relative;\n \" ${isEditable ? 'title=\"Click to edit\"' : ''}>${formatDisplayValue(key, row[key])}</td>`;\n });\n\n html += '</tr>';\n });\n\n html += `\n </tbody>\n </table>\n </div>\n \n <!-- Font Awesome CSS (if not already included) -->\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css\" integrity=\"sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\">\n `;\n\n document.getElementById('devices-table').innerHTML = html;\n\n // Add event listeners\n addEventListeners();\n\n } catch (error) {\n const theme = context.grafana.theme;\n document.getElementById('devices-table').innerHTML = `\n <div style=\"\n padding: ${theme.spacing(2)};\n background: ${theme.colors.error.main}15;\n border: 1px solid ${theme.colors.error.main};\n border-radius: ${theme.shape.borderRadius(1)}px;\n color: ${theme.colors.error.text};\n font-family: ${theme.typography.fontFamily};\n \">\n <strong>SQLite Error:</strong> ${error.message}\n </div>`;\n }\n}\n\n// Helper function to format display values (especially timestamps)\nfunction formatDisplayValue(fieldName, value) {\n if (value === null || value === undefined || value === '') {\n return '<em style=\"color: var(--text-secondary);\">null</em>';\n }\n\n // Handle timestamp fields for SQLite (Unix timestamps)\n const timestampPatterns = [\n /.*_at$/i, /.*_time$/i, /.*timestamp$/i, /^date$/i, /^time$/i\n ];\n \n const isTimestampField = timestampPatterns.some(pattern => pattern.test(fieldName));\n \n if (isTimestampField && /^\\d{10,13}$/.test(String(value))) {\n // Convert Unix timestamp to readable format\n const timestamp = parseInt(value);\n const date = new Date(timestamp > 9999999999 ? timestamp : timestamp * 1000);\n return `<span title=\"Unix: ${value}\">${date.toLocaleString()}</span>`;\n }\n\n return String(value);\n}\n\n// Helper function to detect ID field using common patterns\nfunction detectIdField(fields) {\n // Common ID field patterns (case-insensitive)\n const idPatterns = [\n /^id$/i, // exact match: \"id\"\n /^.*_id$/i, // ends with \"_id\": \"user_id\", \"device_id\"\n /^id_.*$/i, // starts with \"id_\": \"id_user\", \"id_device\"\n /^.*id$/i, // ends with \"id\": \"userid\", \"deviceid\"\n /^uid$/i, // exact match: \"uid\"\n /^uuid$/i, // exact match: \"uuid\"\n /^pk$/i, // exact match: \"pk\" (primary key)\n /^primary_key$/i // exact match: \"primary_key\"\n ];\n\n // Find first field matching ID patterns\n for (const pattern of idPatterns) {\n const idField = fields.find(field => pattern.test(field));\n if (idField) return idField;\n }\n\n // Fallback: return first field (most common case)\n return fields[0];\n}\n\n// Helper function to check if a field is an ID field\nfunction isIdField(fieldName, allFields) {\n return fieldName === detectIdField(allFields);\n}\n\n// Helper function to format SQL values for SQLite\nfunction formatSqlValue(fieldName, value) {\n // Handle null values\n if (value === null || value === 'null' || value === '') {\n return 'NULL';\n }\n\n // Convert to string and trim\n const stringValue = String(value).trim();\n\n // Handle timestamp fields (SQLite specific)\n const timestampPatterns = [\n /.*_at$/i, // ends with \"_at\": created_at, updated_at, last_seen_at\n /.*_time$/i, // ends with \"_time\": start_time, end_time\n /.*timestamp$/i, // ends with \"timestamp\"\n /^date$/i, // exact: \"date\"\n /^time$/i // exact: \"time\"\n ];\n\n const isTimestampField = timestampPatterns.some(pattern => pattern.test(fieldName));\n\n if (isTimestampField) {\n // Check if it's a Unix timestamp (numbers only, reasonable length)\n if (/^\\d{10,13}$/.test(stringValue)) {\n return stringValue; // Keep as number for SQLite\n }\n\n // Check if it's already a valid date string\n const parsedDate = new Date(stringValue);\n if (!isNaN(parsedDate.getTime())) {\n // Convert to Unix timestamp for SQLite (milliseconds to seconds)\n return Math.floor(parsedDate.getTime() / 1000);\n }\n\n // If we can't parse it as a date, treat it as NULL\n return 'NULL';\n }\n\n // Handle numeric fields\n if (/^\\-?\\d+(\\.\\d+)?$/.test(stringValue)) {\n return stringValue; // No quotes for numbers\n }\n\n // Handle boolean fields (SQLite uses 1/0 for boolean)\n if (stringValue.toLowerCase() === 'true' || stringValue.toLowerCase() === 'false') {\n return stringValue.toLowerCase() === 'true' ? '1' : '0';\n }\n\n // Default: treat as string with proper escaping for SQLite\n return `'${stringValue.replace(/'/g, \"''\")}'`;\n}\n\nfunction addEventListeners() {\n const theme = context.grafana.theme;\n\n // Edit icon listeners\n document.querySelectorAll('.edit-icon').forEach(icon => {\n icon.addEventListener('click', function () {\n const rowIndex = this.getAttribute('data-row-index');\n enterEditMode(rowIndex);\n });\n });\n\n // Save icon listeners\n document.querySelectorAll('.save-icon').forEach(icon => {\n icon.addEventListener('click', async function () {\n const deviceId = this.getAttribute('data-device-id');\n const rowIndex = this.getAttribute('data-row-index');\n await saveRow(deviceId, rowIndex);\n });\n });\n\n // Cancel icon listeners\n document.querySelectorAll('.cancel-icon').forEach(icon => {\n icon.addEventListener('click', function () {\n const rowIndex = this.getAttribute('data-row-index');\n cancelEdit(rowIndex);\n });\n });\n\n // Delete icon listeners\n document.querySelectorAll('.delete-icon').forEach(icon => {\n icon.addEventListener('click', async function () {\n const deviceId = this.getAttribute('data-device-id');\n await deleteDevice(deviceId, this);\n });\n });\n\n // Cell click listeners for inline editing\n document.querySelectorAll('.editable-cell').forEach(cell => {\n cell.addEventListener('click', function () {\n const rowIndex = this.getAttribute('data-row-index');\n if (editingCell === null) {\n enterEditMode(rowIndex);\n makeInlineEditable(this);\n }\n });\n });\n\n // Add keyboard navigation support\n document.addEventListener('keydown', handleGlobalKeyboard);\n}\n\n// Global keyboard handler for navigation and shortcuts\nfunction handleGlobalKeyboard(e) {\n // Only handle keyboard events during editing\n if (editingCell === null) return;\n\n if (e.key === 'Escape') {\n e.preventDefault();\n cancelEdit(editingCell);\n }\n\n if (e.ctrlKey && e.key === 's') {\n e.preventDefault();\n const row = document.querySelector(`tr[data-row-index=\"${editingCell}\"]`);\n const saveIcon = row.querySelector('.save-icon');\n if (saveIcon && saveIcon.style.display !== 'none') {\n saveIcon.click();\n }\n }\n}\n\nfunction enterEditMode(rowIndex) {\n if (editingCell !== null) {\n context.grafana.notifyError('Please save or cancel current edit before editing another row');\n return;\n }\n\n const row = document.querySelector(`tr[data-row-index=\"${rowIndex}\"]`);\n const editIcon = row.querySelector('.edit-icon');\n const saveIcon = row.querySelector('.save-icon');\n const cancelIcon = row.querySelector('.cancel-icon');\n\n // Toggle icon visibility\n editIcon.style.display = 'none';\n saveIcon.style.display = 'inline-block';\n cancelIcon.style.display = 'inline-block';\n\n // Store current values before making cells editable\n const cells = row.querySelectorAll('.editable-cell');\n cells.forEach(cell => {\n const cellId = cell.getAttribute('data-cell-id');\n const currentValue = cell.textContent.trim();\n originalValues[cellId] = currentValue;\n });\n\n // Make cells editable\n cells.forEach(cell => {\n makeInlineEditable(cell);\n });\n\n editingCell = rowIndex;\n}\n\nfunction makeInlineEditable(cell) {\n const theme = context.grafana.theme;\n const cellId = cell.getAttribute('data-cell-id');\n const fieldName = cell.getAttribute('data-field');\n const rowIndex = cell.getAttribute('data-row-index');\n \n // Get original value\n let originalValue = cell.getAttribute('data-original-value');\n if (!originalValue) {\n originalValue = originalValues[cellId];\n }\n if (originalValue === undefined || originalValue === null) {\n originalValue = cell.textContent.trim();\n }\n if (!originalValue) {\n originalValue = '';\n }\n\n // Skip ID field editing\n if (fieldName === tableStructure.idField) {\n context.grafana.notifyError('ID field cannot be edited');\n return;\n }\n\n // Create input with better styling\n const input = document.createElement('input');\n input.type = 'text';\n input.value = originalValue;\n input.setAttribute('data-original-value', originalValue);\n input.setAttribute('data-field', fieldName);\n\n // Add placeholder for timestamp fields\n const timestampPatterns = [\n /.*_at$/i, /.*_time$/i, /.*timestamp$/i, /^date$/i, /^time$/i\n ];\n const isTimestampField = timestampPatterns.some(pattern => pattern.test(fieldName));\n\n if (isTimestampField) {\n input.placeholder = 'e.g., 1671234567 (Unix) or 2023-12-17T10:30:00Z (ISO)';\n input.title = 'Enter Unix timestamp or ISO date format';\n }\n\n input.style.cssText = `\n width: 100%;\n background: ${theme.colors.background.canvas};\n border: 1px solid ${theme.colors.border.medium};\n color: ${theme.colors.text.primary};\n font-family: ${theme.typography.fontFamily};\n font-size: ${theme.typography.fontSize};\n padding: ${theme.spacing(1)};\n border-radius: ${theme.shape.radius.default}px;\n box-sizing: border-box;\n outline: none;\n transition: all 0.2s ease;\n box-shadow: none;\n `;\n\n // Enhanced keyboard navigation and input handling\n input.addEventListener('keydown', function (e) {\n handleInputKeyboard(e, cell, input);\n });\n\n input.addEventListener('focus', function () {\n currentFocusedInput = input;\n this.style.borderColor = theme.colors.primary.border;\n this.style.boxShadow = `0 0 0 2px ${theme.colors.primary.transparent}`;\n });\n\n input.addEventListener('blur', function () {\n this.style.borderColor = theme.colors.border.medium;\n this.style.boxShadow = 'none';\n });\n\n // Validate input on change\n input.addEventListener('input', function () {\n validateInput(input, theme);\n });\n\n cell.innerHTML = '';\n cell.appendChild(input);\n input.focus();\n input.select();\n currentFocusedInput = input;\n}\n\n// Enhanced keyboard handling for input fields\nfunction handleInputKeyboard(e, cell, input) {\n switch (e.key) {\n case 'Enter':\n e.preventDefault();\n const nextCell = getNextEditableCell(cell);\n if (nextCell) {\n makeInlineEditable(nextCell);\n } else {\n // If last cell, save the row\n const row = cell.closest('tr');\n const saveIcon = row.querySelector('.save-icon');\n if (saveIcon && saveIcon.style.display !== 'none') {\n saveIcon.click();\n }\n }\n break;\n\n case 'Tab':\n e.preventDefault();\n if (e.shiftKey) {\n // Shift+Tab: Move to previous editable cell\n const prevCell = getPreviousEditableCell(cell);\n if (prevCell) {\n makeInlineEditable(prevCell);\n }\n } else {\n // Tab: Move to next editable cell\n const nextCell = getNextEditableCell(cell);\n if (nextCell) {\n makeInlineEditable(nextCell);\n }\n }\n break;\n\n case 'Escape':\n e.preventDefault();\n cancelEdit(editingCell);\n break;\n }\n}\n\n// Input validation with visual feedback\nfunction validateInput(input, theme) {\n const value = input.value.trim();\n const fieldName = input.getAttribute('data-field');\n\n // Basic validation\n if (value === '') {\n input.style.borderColor = theme.colors.warning.border;\n input.style.boxShadow = `0 0 0 2px ${theme.colors.warning.transparent}`;\n input.title = 'Field cannot be empty';\n return false;\n }\n\n // Validate timestamp fields\n const timestampPatterns = [\n /.*_at$/i, /.*_time$/i, /.*timestamp$/i, /^date$/i, /^time$/i\n ];\n\n const isTimestampField = timestampPatterns.some(pattern => pattern.test(fieldName));\n\n if (isTimestampField) {\n // Check if it's a valid Unix timestamp\n if (/^\\d{10,13}$/.test(value)) {\n const timestamp = parseInt(value);\n const date = new Date(timestamp > 9999999999 ? timestamp : timestamp * 1000);\n\n if (isNaN(date.getTime())) {\n input.style.borderColor = theme.colors.error.border;\n input.style.boxShadow = `0 0 0 2px ${theme.colors.error.transparent}`;\n input.title = 'Invalid timestamp format';\n return false;\n }\n\n // Show parsed date in tooltip for validation\n input.title = `Will be saved as: ${date.toISOString()}`;\n input.style.borderColor = theme.colors.success.border;\n input.style.boxShadow = `0 0 0 2px ${theme.colors.success.transparent}`;\n return true;\n }\n\n // Check if it's a valid date string\n const parsedDate = new Date(value);\n if (!isNaN(parsedDate.getTime())) {\n input.title = `Will be saved as: ${parsedDate.toISOString()}`;\n input.style.borderColor = theme.colors.success.border;\n input.style.boxShadow = `0 0 0 2px ${theme.colors.success.transparent}`;\n return true;\n }\n\n // Invalid timestamp format\n input.style.borderColor = theme.colors.error.border;\n input.style.boxShadow = `0 0 0 2px ${theme.colors.error.transparent}`;\n input.title = 'Invalid date/time format. Use Unix timestamp (e.g., 1671234567) or ISO date (e.g., 2023-12-17T10:30:00Z)';\n return false;\n }\n\n // Default validation passed\n input.style.borderColor = theme.colors.border.medium;\n input.style.boxShadow = 'none';\n input.title = '';\n return true;\n}\n\nfunction getNextEditableCell(currentCell) {\n const row = currentCell.closest('tr');\n const allCells = Array.from(row.querySelectorAll('.editable-cell'));\n const currentIndex = allCells.indexOf(currentCell);\n\n // Return next editable cell in same row, or first cell of next row\n if (currentIndex < allCells.length - 1) {\n return allCells[currentIndex + 1];\n } else {\n // Move to next row if available\n const nextRow = row.nextElementSibling;\n if (nextRow) {\n const firstEditableCell = nextRow.querySelector('.editable-cell');\n if (firstEditableCell) {\n // Switch to next row edit mode\n const nextRowIndex = nextRow.getAttribute('data-row-index');\n if (nextRowIndex !== editingCell) {\n cancelEdit(editingCell);\n enterEditMode(nextRowIndex);\n return firstEditableCell;\n }\n }\n }\n }\n return null;\n}\n\nfunction getPreviousEditableCell(currentCell) {\n const row = currentCell.closest('tr');\n const allCells = Array.from(row.querySelectorAll('.editable-cell'));\n const currentIndex = allCells.indexOf(currentCell);\n\n // Return previous editable cell in same row, or last cell of previous row\n if (currentIndex > 0) {\n return allCells[currentIndex - 1];\n } else {\n // Move to previous row if available\n const prevRow = row.previousElementSibling;\n if (prevRow) {\n const lastEditableCell = prevRow.querySelector('.editable-cell:last-of-type');\n if (lastEditableCell) {\n // Switch to previous row edit mode\n const prevRowIndex = prevRow.getAttribute('data-row-index');\n if (prevRowIndex !== editingCell) {\n cancelEdit(editingCell);\n enterEditMode(prevRowIndex);\n return lastEditableCell;\n }\n }\n }\n }\n return null;\n}\n\nasync function saveRow(deviceId, rowIndex) {\n const theme = context.grafana.theme;\n const row = document.querySelector(`tr[data-row-index=\"${rowIndex}\"]`);\n const cells = row.querySelectorAll('.editable-cell');\n\n // Collect updated data from editable fields only\n const updatedData = {};\n const changes = {};\n let hasChanges = false;\n let hasValidationErrors = false;\n\n cells.forEach(cell => {\n const fieldName = cell.getAttribute('data-field');\n const cellId = cell.getAttribute('data-cell-id');\n let newValue;\n\n const input = cell.querySelector('input');\n if (input) {\n newValue = input.value.trim();\n\n // Use the enhanced validation function\n if (!validateInput(input, theme)) {\n hasValidationErrors = true;\n return;\n }\n } else {\n newValue = cell.textContent.trim();\n }\n\n updatedData[fieldName] = newValue;\n\n // Check for changes\n if (originalValues[cellId] !== newValue) {\n hasChanges = true;\n changes[fieldName] = {\n old: originalValues[cellId],\n new: newValue\n };\n }\n });\n\n if (hasValidationErrors) {\n context.grafana.notifyError('Please fix validation errors before saving');\n return;\n }\n\n if (!hasChanges) {\n exitEditMode(rowIndex);\n context.grafana.notifySuccess('No changes to save');\n return;\n }\n\n try {\n // Build UPDATE SQL query for SQLite with only changed fields\n const updates = Object.keys(changes)\n .map(key => {\n const newValue = changes[key].new;\n return `${key} = ${formatSqlValue(key, newValue)}`;\n })\n .join(', ');\n\n const updateSql = `UPDATE devices SET ${updates} WHERE ${tableStructure.idField} = ${deviceId}`;\n\n // Show saving state\n const saveIcon = row.querySelector('.save-icon');\n const originalSaveClass = saveIcon.className;\n saveIcon.className = 'save-icon fas fa-spinner fa-spin';\n saveIcon.style.pointerEvents = 'none';\n saveIcon.title = 'Saving to SQLite...';\n\n console.log('Executing SQLite UPDATE:', updateSql);\n\n // Execute UPDATE SQL using SQLite\n await context.dataSource.sql(currentDataSourceUid, updateSql);\n\n // Update UI with new values and reset original values\n cells.forEach(cell => {\n const fieldName = cell.getAttribute('data-field');\n const cellId = cell.getAttribute('data-cell-id');\n const newValue = updatedData[fieldName];\n\n cell.innerHTML = formatDisplayValue(fieldName, newValue);\n originalValues[cellId] = newValue; // Update stored original values\n });\n\n // Restore save icon before exiting edit mode\n saveIcon.className = originalSaveClass;\n saveIcon.style.pointerEvents = 'auto';\n saveIcon.title = 'Save changes';\n\n exitEditMode(rowIndex);\n\n // Show detailed success message\n const changedFields = Object.keys(changes).join(', ');\n context.grafana.notifySuccess(`SQLite device updated successfully. Changed: ${changedFields}`);\n\n } catch (error) {\n // Re-enable save icon on error\n const saveIcon = row.querySelector('.save-icon');\n saveIcon.className = originalSaveClass;\n saveIcon.style.pointerEvents = 'auto';\n saveIcon.title = 'Save changes';\n\n // Enhanced error handling for SQLite\n let errorMessage = 'Unknown error occurred';\n let errorDetails = '';\n\n if (error && error.message) {\n errorMessage = error.message;\n }\n\n // Check if it's a database query error with additional details\n if (error && error.data && error.data.results) {\n const results = error.data.results;\n Object.keys(results).forEach(key => {\n const result = results[key];\n if (result.error) {\n errorMessage = result.error;\n if (result.errorSource) {\n errorDetails += `Source: ${result.errorSource}. `;\n }\n\n // Extract the executed query for debugging\n if (result.frames && result.frames[0] && result.frames[0].schema && result.frames[0].schema.meta) {\n const executedQuery = result.frames[0].schema.meta.executedQueryString;\n if (executedQuery) {\n console.error('Failed SQLite Query:', executedQuery);\n errorDetails += `Query: ${executedQuery.substring(0, 100)}${executedQuery.length > 100 ? '...' : ''}`;\n }\n }\n }\n });\n }\n\n // Show comprehensive error notification\n const fullErrorMessage = `SQLite update failed: ${errorMessage}${errorDetails ? ' | ' + errorDetails : ''}`;\n context.grafana.notifyError(fullErrorMessage);\n\n console.error('SQLite save operation failed:', {\n originalError: error,\n processedMessage: fullErrorMessage,\n deviceId: deviceId,\n changes: changes\n });\n }\n}\n\nfunction cancelEdit(rowIndex) {\n const row = document.querySelector(`tr[data-row-index=\"${rowIndex}\"]`);\n const cells = row.querySelectorAll('.editable-cell');\n\n // Restore original values\n cells.forEach(cell => {\n const cellId = cell.getAttribute('data-cell-id');\n const fieldName = cell.getAttribute('data-field');\n const originalValue = originalValues[cellId];\n cell.innerHTML = formatDisplayValue(fieldName, originalValue);\n });\n\n exitEditMode(rowIndex);\n context.grafana.notifySuccess('Edit cancelled');\n}\n\nfunction exitEditMode(rowIndex) {\n const row = document.querySelector(`tr[data-row-index=\"${rowIndex}\"]`);\n const editIcon = row.querySelector('.edit-icon');\n const saveIcon = row.querySelector('.save-icon');\n const cancelIcon = row.querySelector('.cancel-icon');\n\n // Toggle icon visibility\n editIcon.style.display = 'inline-block';\n saveIcon.style.display = 'none';\n cancelIcon.style.display = 'none';\n\n // Reset save icon to original state (in case it was a spinner)\n saveIcon.className = 'save-icon fas fa-check';\n saveIcon.style.pointerEvents = 'auto';\n saveIcon.title = 'Save changes';\n\n // Clean up state\n editingCell = null;\n currentFocusedInput = null;\n}\n\nasync function deleteDevice(deviceId, iconElement) {\n if (!confirm(`Are you sure you want to delete device ${deviceId}?`)) {\n return;\n }\n\n const theme = context.grafana.theme;\n\n // Disable icon during delete\n const originalClass = iconElement.className;\n iconElement.className = 'delete-icon fas fa-spinner fa-spin';\n iconElement.style.pointerEvents = 'none';\n iconElement.title = 'Deleting from SQLite...';\n\n try {\n // Execute DELETE SQL using SQLite syntax\n const deleteSql = `DELETE FROM devices WHERE ${tableStructure.idField} = ${deviceId}`;\n console.log('Executing SQLite DELETE:', deleteSql);\n \n await context.dataSource.sql(currentDataSourceUid, deleteSql);\n\n // Remove the row from table with smooth animation\n const row = iconElement.closest('tr');\n row.style.transition = 'opacity 0.3s ease';\n row.style.opacity = '0';\n setTimeout(() => row.remove(), 300);\n\n // Show success notification\n context.grafana.notifySuccess(`SQLite device ${deviceId} deleted successfully`);\n\n } catch (error) {\n // Re-enable icon on error\n iconElement.className = originalClass;\n iconElement.style.pointerEvents = 'auto';\n iconElement.title = 'Delete row';\n\n // Enhanced error handling for delete operation\n let errorMessage = 'Unknown error occurred';\n\n if (error && error.message) {\n errorMessage = error.message;\n }\n\n // Check for database-specific errors\n if (error && error.data && error.data.results) {\n const results = error.data.results;\n Object.keys(results).forEach(key => {\n const result = results[key];\n if (result.error) {\n errorMessage = result.error;\n }\n });\n }\n\n // Show comprehensive error notification\n const fullErrorMessage = `SQLite delete failed: ${errorMessage}`;\n context.grafana.notifyError(fullErrorMessage);\n\n console.error('SQLite delete operation failed:', {\n originalError: error,\n processedMessage: fullErrorMessage,\n deviceId: deviceId\n });\n }\n}\n\n// Clean up function to remove global event listeners\nfunction cleanup() {\n document.removeEventListener('keydown', handleGlobalKeyboard);\n editingCell = null;\n currentFocusedInput = null;\n}\n\n// Initialize: Load devices when the script runs\ndocument.getElementById('load-devices').addEventListener('click', loadDevices);", "externalStyles": [], "stylesRemoteUrl": "", "styles": "", "dataSource": { "enableDataSourceQueries": true, "queryTimeout": 30000, "showQueryErrors": true } } } ], "refresh": "", "schemaVersion": 39, "tags": [ "sqlite", "device-management", "crud" ], "templating": { "list": [] }, "time": { "from": "now-6h", "to": "now" }, "timepicker": {}, "timezone": "", "title": "SQLite based device management", "uid": "sqlite_device_management", "version": 1 }