729 lines
20 KiB
JavaScript
729 lines
20 KiB
JavaScript
/**
|
|
* 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
|
|
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>'
|
|
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
|
|
|
return `
|
|
<button class="update-badge ${this.state.updateType}" aria-label="Updates available">
|
|
${icon}
|
|
<span class="update-badge-dot"></span>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 = `
|
|
<div class="update-modal-content">
|
|
<div class="update-modal-icon docker">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
|
</svg>
|
|
</div>
|
|
<h3 class="update-modal-title">Docker Update Available</h3>
|
|
<div class="update-modal-versions">
|
|
<div class="version-item">
|
|
<span class="version-label">Current:</span>
|
|
<code class="version-value">${this.escapeHtml(currentVersion)}</code>
|
|
</div>
|
|
<div class="version-item">
|
|
<span class="version-label">Latest:</span>
|
|
<code class="version-value latest">${this.escapeHtml(latestVersion)}</code>
|
|
</div>
|
|
</div>
|
|
${releaseNotes ? `
|
|
<div class="update-modal-notes">
|
|
<h4>Release Notes</h4>
|
|
<div class="release-notes-text">${this.escapeHtml(releaseNotes)}</div>
|
|
</div>
|
|
` : ''}
|
|
<div class="update-modal-command">
|
|
<h4>Upgrade Command</h4>
|
|
<div class="command-box">
|
|
<code>docker pull guardrail/mcp-server:${this.escapeHtml(latestVersion)}</code>
|
|
<button class="command-copy" data-command="docker pull guardrail/mcp-server:${this.escapeHtml(latestVersion)}" title="Copy to clipboard">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="update-modal-content">
|
|
<div class="update-modal-icon guardrail">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
</svg>
|
|
</div>
|
|
<h3 class="update-modal-title">Guardrail Updates Available</h3>
|
|
<p class="update-modal-subtitle">New guardrail definitions are available for synchronization.</p>
|
|
<div class="update-modal-stats">
|
|
<div class="stat-item new">
|
|
<span class="stat-value">${files.new || 0}</span>
|
|
<span class="stat-label">New</span>
|
|
</div>
|
|
<div class="stat-item modified">
|
|
<span class="stat-value">${files.modified || 0}</span>
|
|
<span class="stat-label">Modified</span>
|
|
</div>
|
|
<div class="stat-item deleted">
|
|
<span class="stat-value">${files.deleted || 0}</span>
|
|
<span class="stat-label">Deleted</span>
|
|
</div>
|
|
</div>
|
|
<div class="update-modal-info">
|
|
<p><strong>Last sync:</strong> ${lastSync}</p>
|
|
</div>
|
|
<div class="update-modal-actions">
|
|
<button class="btn btn-secondary" onclick="this.closest('.modal').querySelector('.modal-close').click()">Later</button>
|
|
<button class="btn btn-primary update-sync-btn" ${this.state.isSyncing ? 'disabled' : ''}>
|
|
${this.state.isSyncing ? `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation: spin 1s linear infinite;">
|
|
<circle cx="12" cy="12" r="10" stroke-dasharray="60" stroke-dashoffset="20"/>
|
|
</svg>
|
|
Syncing...
|
|
` : `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="23 4 23 10 17 10"/>
|
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
|
</svg>
|
|
Sync Now
|
|
`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation: spin 1s linear infinite;">
|
|
<circle cx="12" cy="12" r="10" stroke-dasharray="60" stroke-dashoffset="20"/>
|
|
</svg>
|
|
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 = `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="23 4 23 10 17 10"/>
|
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
|
</svg>
|
|
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;
|