/** * Update Notifier Component * Checks for updates on page load, shows notification badges, and handles sync actions */ class UpdateNotifier { constructor(options = {}) { this.options = { checkInterval: 24 * 60 * 60 * 1000, // 24 hours in milliseconds storageKey: 'guardrail_last_update_check', ...options }; this.state = { updateAvailable: false, updateType: null, // 'docker' or 'guardrail' updateData: null, isChecking: false, isSyncing: false }; this.elements = {}; this.init(); } /** * Initialize the update notifier */ init() { this.createStyles(); // Defer the initial check to avoid race conditions during app initialization // Ensures window.api and other dependencies are fully ready if (this.shouldCheckOnLoad()) { setTimeout(() => { // Double-check API availability before making the call if (window.api && typeof window.api.getUpdateStatus === 'function') { this.checkForUpdates(); } else { console.warn('UpdateNotifier: API not available, skipping update check'); } }, 100); } } /** * Check if we should check for updates (daily limit) */ shouldCheckOnLoad() { const lastCheck = localStorage.getItem(this.options.storageKey); if (!lastCheck) return true; const lastCheckTime = parseInt(lastCheck, 10); const now = Date.now(); return (now - lastCheckTime) >= this.options.checkInterval; } /** * Update last check timestamp */ recordCheck() { localStorage.setItem(this.options.storageKey, Date.now().toString()); } /** * Check for updates via API */ async checkForUpdates() { if (this.state.isChecking) return; this.state.isChecking = true; try { const status = await window.api.getUpdateStatus(); this.recordCheck(); if (status && status.has_update) { this.state.updateAvailable = true; this.state.updateType = status.type; // 'docker' or 'guardrail' this.state.updateData = status; this.showNotification(); } } catch (error) { console.error('Failed to check for updates:', error); } finally { this.state.isChecking = false; } } /** * Show notification badge in header */ showNotification() { const header = document.querySelector('.header'); if (!header) return; // Check if notifier already exists let notifier = header.querySelector('.update-notifier'); if (notifier) { this.updateBadge(notifier); return; } // Create notifier element notifier = document.createElement('div'); notifier.className = 'update-notifier'; notifier.innerHTML = this.renderBadge(); // Insert before the first child or append to header const headerActions = header.querySelector('.header-actions'); if (headerActions) { headerActions.insertBefore(notifier, headerActions.firstChild); } else { header.appendChild(notifier); } this.elements.notifier = notifier; this.attachBadgeEvents(notifier); } /** * Render notification badge */ renderBadge() { const isDocker = this.state.updateType === 'docker'; const icon = isDocker ? '' : ''; return ` `; } /** * Update existing badge */ updateBadge(notifier) { const badge = notifier.querySelector('.update-badge'); if (badge) { badge.className = `update-badge ${this.state.updateType}`; } } /** * Attach events to badge */ attachBadgeEvents(notifier) { const badge = notifier.querySelector('.update-badge'); if (badge) { badge.addEventListener('click', () => this.showUpdateModal()); } } /** * Show update details modal */ showUpdateModal() { if (this.state.updateType === 'docker') { this.showDockerUpdateModal(); } else { this.showGuardrailUpdateModal(); } } /** * Show Docker update modal with upgrade instructions */ showDockerUpdateModal() { const data = this.state.updateData || {}; const currentVersion = data.current_version || 'unknown'; const latestVersion = data.latest_version || 'unknown'; const releaseNotes = data.release_notes || ''; const content = `

Docker Update Available

Current: ${this.escapeHtml(currentVersion)}
Latest: ${this.escapeHtml(latestVersion)}
${releaseNotes ? `

Release Notes

${this.escapeHtml(releaseNotes)}
` : ''}

Upgrade Command

docker pull guardrail/mcp-server:${this.escapeHtml(latestVersion)}
`; const modal = new Modal({ title: 'Update Available', size: 'md', showFooter: false, closable: true }); modal.open(content); this.attachModalEvents(modal); } /** * Show Guardrail update modal with sync button */ showGuardrailUpdateModal() { const data = this.state.updateData || {}; const files = data.files || { new: 0, modified: 0, deleted: 0 }; const lastSync = data.last_sync ? new Date(data.last_sync).toLocaleString() : 'Unknown'; const content = `

Guardrail Updates Available

New guardrail definitions are available for synchronization.

${files.new || 0} New
${files.modified || 0} Modified
${files.deleted || 0} Deleted

Last sync: ${lastSync}

`; const modal = new Modal({ title: 'Sync Guardrails', size: 'sm', showFooter: false, closable: true }); modal.open(content); this.attachSyncEvent(modal); } /** * Attach sync button event */ attachSyncEvent(modal) { const syncBtn = modal.element.querySelector('.update-sync-btn'); if (syncBtn) { syncBtn.addEventListener('click', () => this.handleSync(modal)); } } /** * Handle guardrail sync */ async handleSync(modal) { if (this.state.isSyncing) return; this.state.isSyncing = true; // Update button state const syncBtn = modal.element.querySelector('.update-sync-btn'); if (syncBtn) { syncBtn.disabled = true; syncBtn.innerHTML = ` Syncing... `; } try { const result = await window.api.syncGuardrails(); modal.close(); Toast.success('Guardrails synchronized successfully', 'Sync Complete'); // Clear update state this.state.updateAvailable = false; this.state.updateType = null; this.state.updateData = null; // Remove badge this.removeBadge(); // Refresh the page if we're on the rules/documents page const currentPath = window.location.hash.slice(1); if (currentPath === '/rules' || currentPath === '/documents') { window.router.refresh(); } } catch (error) { Toast.error(error.message || 'Failed to sync guardrails', 'Sync Failed'); // Reset button state if (syncBtn) { syncBtn.disabled = false; syncBtn.innerHTML = ` Sync Now `; } } finally { this.state.isSyncing = false; } } /** * Attach modal events (copy button) */ attachModalEvents(modal) { const copyBtn = modal.element.querySelector('.command-copy'); if (copyBtn) { copyBtn.addEventListener('click', () => { const command = copyBtn.dataset.command; navigator.clipboard.writeText(command).then(() => { Toast.success('Command copied to clipboard'); }).catch(() => { Toast.error('Failed to copy command'); }); }); } } /** * Remove notification badge */ removeBadge() { const notifier = document.querySelector('.update-notifier'); if (notifier) { notifier.remove(); } this.elements.notifier = null; } /** * Create component styles */ createStyles() { if (document.getElementById('update-notifier-styles')) return; const styles = document.createElement('style'); styles.id = 'update-notifier-styles'; styles.textContent = ` /* Badge Styles */ .update-notifier { display: inline-flex; align-items: center; } .update-badge { position: relative; display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); cursor: pointer; color: var(--color-text-secondary); transition: all 0.15s ease; } .update-badge:hover { background: var(--color-surface-hover); color: var(--color-text-primary); } .update-badge.docker { color: var(--color-info); } .update-badge.guardrail { color: var(--color-warning); } .update-badge-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; background: var(--color-error); border-radius: 50%; animation: pulse 2s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* Modal Content Styles */ .update-modal-content { text-align: center; padding: 0.5rem; } .update-modal-icon { display: inline-flex; align-items: center; justify-content: center; width: 80px; height: 80px; border-radius: 50%; margin-bottom: 1rem; } .update-modal-icon.docker { background: rgba(59, 130, 246, 0.1); color: var(--color-info); } .update-modal-icon.guardrail { background: rgba(245, 158, 11, 0.1); color: var(--color-warning); } .update-modal-title { font-size: var(--text-xl); font-weight: var(--font-semibold); color: var(--color-text-primary); margin: 0 0 0.5rem; } .update-modal-subtitle { color: var(--color-text-secondary); margin: 0 0 1.5rem; } /* Version Info */ .update-modal-versions { display: flex; justify-content: center; gap: 2rem; margin-bottom: 1.5rem; padding: 1rem; background: var(--color-surface-elevated); border-radius: var(--radius-lg); } .version-item { display: flex; flex-direction: column; gap: 0.25rem; } .version-label { font-size: var(--text-xs); color: var(--color-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; } .version-value { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--color-text-secondary); background: var(--color-surface); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); } .version-value.latest { color: var(--color-success); background: rgba(34, 197, 94, 0.1); } /* Release Notes */ .update-modal-notes { text-align: left; margin-bottom: 1.5rem; } .update-modal-notes h4 { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-primary); margin: 0 0 0.75rem; } .release-notes-text { font-size: var(--text-sm); color: var(--color-text-secondary); line-height: 1.6; max-height: 150px; overflow-y: auto; padding: 0.75rem; background: var(--color-surface-elevated); border-radius: var(--radius-md); white-space: pre-wrap; } /* Command Box */ .update-modal-command { text-align: left; margin-bottom: 1rem; } .update-modal-command h4 { font-size: var(--text-sm); font-weight: var(--font-semibold); color: var(--color-text-primary); margin: 0 0 0.75rem; } .command-box { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem; background: var(--color-surface-elevated); border: 1px solid var(--color-border); border-radius: var(--radius-md); } .command-box code { flex: 1; font-family: var(--font-mono); font-size: var(--text-sm); color: var(--color-text-primary); word-break: break-all; } .command-copy { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); cursor: pointer; color: var(--color-text-secondary); transition: all 0.15s ease; flex-shrink: 0; } .command-copy:hover { background: var(--color-surface-hover); color: var(--color-text-primary); } /* Stats Grid */ .update-modal-stats { display: flex; justify-content: center; gap: 1rem; margin-bottom: 1.5rem; } .stat-item { display: flex; flex-direction: column; align-items: center; min-width: 80px; padding: 1rem; background: var(--color-surface-elevated); border-radius: var(--radius-lg); border: 2px solid transparent; } .stat-item.new { border-color: rgba(34, 197, 94, 0.3); } .stat-item.modified { border-color: rgba(245, 158, 11, 0.3); } .stat-item.deleted { border-color: rgba(239, 68, 68, 0.3); } .stat-value { font-size: var(--text-2xl); font-weight: var(--font-bold); color: var(--color-text-primary); line-height: 1; } .stat-item.new .stat-value { color: var(--color-success); } .stat-item.modified .stat-value { color: var(--color-warning); } .stat-item.deleted .stat-value { color: var(--color-error); } .stat-label { font-size: var(--text-xs); color: var(--color-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.5rem; } .update-modal-info { margin-bottom: 1.5rem; padding: 0.75rem; background: var(--color-surface-elevated); border-radius: var(--radius-md); } .update-modal-info p { margin: 0; font-size: var(--text-sm); color: var(--color-text-secondary); } .update-modal-actions { display: flex; justify-content: center; gap: 0.75rem; } .update-modal-actions .btn { display: inline-flex; align-items: center; gap: 0.5rem; } /* Spin animation for loading */ @keyframes spin { to { transform: rotate(360deg); } } `; document.head.appendChild(styles); } /** * Escape HTML to prevent XSS */ escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Force a check for updates (can be called manually) */ async forceCheck() { this.state.updateAvailable = false; this.state.updateType = null; this.state.updateData = null; this.removeBadge(); await this.checkForUpdates(); } /** * Get current update status */ getStatus() { return { hasUpdate: this.state.updateAvailable, type: this.state.updateType, isChecking: this.state.isChecking, isSyncing: this.state.isSyncing }; } } window.UpdateNotifier = UpdateNotifier;