tuanchris 3 mēneši atpakaļ
vecāks
revīzija
857bf90c8a
5 mainītis faili ar 510 papildinājumiem un 60 dzēšanām
  1. 92 1
      main.py
  2. 124 28
      modules/update/update_manager.py
  3. 21 3
      static/js/base.js
  4. 184 25
      static/js/settings.js
  5. 89 3
      templates/settings.html

+ 92 - 1
main.py

@@ -318,6 +318,7 @@ class GetCoordinatesRequest(BaseModel):
 # Store active WebSocket connections
 active_status_connections = set()
 active_cache_progress_connections = set()
+active_update_progress_connections = set()
 
 @app.websocket("/ws/status")
 async def websocket_status_endpoint(websocket: WebSocket):
@@ -1174,7 +1175,7 @@ async def check_updates():
 async def update_software():
     logger.info("Starting software update process")
     success, error_message, error_log = update_manager.update_software()
-    
+
     if success:
         logger.info("Software update completed successfully")
         return {"success": True}
@@ -1188,6 +1189,96 @@ async def update_software():
             }
         )
 
+@app.get("/api/versions")
+async def get_available_versions():
+    """Get list of available Git tags and branches."""
+    try:
+        versions = update_manager.list_available_versions()
+        return {
+            "success": True,
+            "versions": versions
+        }
+    except Exception as e:
+        logger.error(f"Error fetching versions: {e}")
+        raise HTTPException(
+            status_code=500,
+            detail={"error": "Failed to fetch available versions"}
+        )
+
+class UpdateVersionRequest(BaseModel):
+    version: str
+
+@app.post("/api/update_to_version")
+async def update_to_version(request: UpdateVersionRequest, background_tasks: BackgroundTasks):
+    """Update to a specific version."""
+    try:
+        version = request.version if request.version != "latest" else None
+        logger.info(f"Starting update to version: {version or 'latest'}")
+
+        # Define callback to broadcast log messages
+        async def send_log(message: str):
+            """Send log message to all connected WebSocket clients."""
+            disconnected = set()
+            for websocket in active_update_progress_connections:
+                try:
+                    await websocket.send_json({
+                        "type": "log",
+                        "message": message
+                    })
+                except Exception:
+                    disconnected.add(websocket)
+            active_update_progress_connections.difference_update(disconnected)
+
+        # Run update in background
+        def run_update():
+            import asyncio
+            loop = asyncio.new_event_loop()
+            asyncio.set_event_loop(loop)
+
+            def log_callback(message: str):
+                loop.run_until_complete(send_log(message))
+
+            success, error_message, error_log = update_manager.update_software(
+                version=version,
+                log_callback=log_callback
+            )
+
+            # Send completion message
+            loop.run_until_complete(send_log(
+                "✓ Update completed successfully!" if success else f"✗ Update failed: {error_message}"
+            ))
+            loop.close()
+
+        background_tasks.add_task(run_update)
+
+        return {"success": True, "message": "Update started"}
+
+    except Exception as e:
+        logger.error(f"Error starting update: {e}")
+        raise HTTPException(
+            status_code=500,
+            detail={"error": str(e)}
+        )
+
+@app.websocket("/ws/update-progress")
+async def websocket_update_progress_endpoint(websocket: WebSocket):
+    """WebSocket endpoint for streaming update progress logs."""
+    await websocket.accept()
+    active_update_progress_connections.add(websocket)
+    logger.info("Update progress WebSocket connection established")
+
+    try:
+        # Keep connection alive
+        while True:
+            # Wait for messages (client won't send any, just keep alive)
+            await websocket.receive_text()
+    except WebSocketDisconnect:
+        active_update_progress_connections.discard(websocket)
+        logger.info("Update progress WebSocket connection closed")
+    except Exception as e:
+        logger.error(f"Update progress WebSocket error: {e}")
+        active_update_progress_connections.discard(websocket)
+
 @app.post("/set_wled_ip")
 async def set_wled_ip(request: WLEDRequest):
     """Legacy endpoint for backward compatibility - sets WLED as LED provider"""

+ 124 - 28
modules/update/update_manager.py

@@ -1,6 +1,7 @@
 import os
 import subprocess
 import logging
+from typing import Dict, List, Optional, Tuple, Callable
 
 # Configure logging
 logger = logging.getLogger(__name__)
@@ -50,43 +51,138 @@ def check_git_updates():
             "latest_local_tag": None,
         }
 
-def update_software():
-    """Update the software to the latest version."""
+def list_available_versions() -> Dict[str, List[str]]:
+    """List all available Git tags and branches."""
+    try:
+        logger.debug("Fetching available versions")
+        # Fetch latest from remote
+        subprocess.run(["git", "fetch", "--all", "--tags", "--force"], check=True, capture_output=True)
+
+        # Get all tags, sorted by version (newest first)
+        tags_output = subprocess.check_output(
+            ["git", "tag", "--sort=-version:refname"],
+            text=True
+        ).strip()
+        tags = [tag for tag in tags_output.split('\n') if tag]
+
+        # Get all remote branches
+        branches_output = subprocess.check_output(
+            ["git", "branch", "-r", "--format=%(refname:short)"],
+            text=True
+        ).strip()
+        # Filter out HEAD and extract branch names
+        branches = []
+        for branch in branches_output.split('\n'):
+            if branch and not branch.endswith('/HEAD'):
+                # Remove 'origin/' prefix
+                branch_name = branch.replace('origin/', '')
+                if branch_name not in ['HEAD']:
+                    branches.append(branch_name)
+
+        logger.info(f"Found {len(tags)} tags and {len(branches)} branches")
+        return {
+            "tags": tags,
+            "branches": branches
+        }
+    except subprocess.CalledProcessError as e:
+        logger.error(f"Error listing versions: {e}")
+        return {
+            "tags": [],
+            "branches": []
+        }
+
+def update_software(version: Optional[str] = None, log_callback: Optional[Callable[[str], None]] = None):
+    """Update the software to the specified version or latest."""
     error_log = []
-    logger.info("Starting software update process")
 
-    def run_command(command, error_message):
+    def log(message: str):
+        """Log message and call callback if provided."""
+        logger.info(message)
+        if log_callback:
+            log_callback(message)
+
+    log("Starting software update process")
+
+    def run_command_with_output(command, description):
+        """Run command and stream output to log callback."""
         try:
-            logger.debug(f"Running command: {' '.join(command)}")
-            subprocess.run(command, check=True)
-        except subprocess.CalledProcessError as e:
-            logger.error(f"{error_message}: {e}")
-            error_log.append(error_message)
+            log(f"Running: {description}")
+            log(f"Command: {' '.join(command)}")
+
+            # Run command and capture output in real-time
+            process = subprocess.Popen(
+                command,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                text=True,
+                bufsize=1
+            )
+
+            # Stream output line by line
+            for line in iter(process.stdout.readline, ''):
+                if line:
+                    log(line.rstrip())
 
+            process.wait()
+
+            if process.returncode != 0:
+                error_msg = f"{description} failed with return code {process.returncode}"
+                log(f"ERROR: {error_msg}")
+                error_log.append(error_msg)
+                return False
+
+            log(f"✓ {description} completed successfully")
+            return True
+
+        except Exception as e:
+            error_msg = f"{description} failed: {str(e)}"
+            log(f"ERROR: {error_msg}")
+            error_log.append(error_msg)
+            return False
+
+    # Determine target version
     try:
-        subprocess.run(["git", "fetch", "--tags"], check=True)
-        latest_remote_tag = subprocess.check_output(
-            ["git", "describe", "--tags", "--abbrev=0", "origin/main"]
-        ).strip().decode()
-        logger.info(f"Latest remote tag: {latest_remote_tag}")
+        log("Fetching latest version information...")
+        subprocess.run(["git", "fetch", "--all", "--tags", "--force"], check=True, capture_output=True)
+
+        if not version or version == "latest":
+            # Get latest tag
+            target_version = subprocess.check_output(
+                ["git", "describe", "--tags", "--abbrev=0", "origin/main"]
+            ).strip().decode()
+            log(f"Target version: {target_version} (latest)")
+        else:
+            target_version = version
+            log(f"Target version: {target_version} (user selected)")
+
     except subprocess.CalledProcessError as e:
-        error_msg = f"Failed to fetch tags or get latest remote tag: {e}"
-        logger.error(error_msg)
+        error_msg = f"Failed to fetch version information: {e}"
+        log(f"ERROR: {error_msg}")
         error_log.append(error_msg)
         return False, error_msg, error_log
 
-    run_command(["git", "checkout", latest_remote_tag, '--force'], f"Failed to checkout version {latest_remote_tag}")
-    run_command(["docker", "compose", "pull"], "Failed to fetch Docker containers")
-    run_command(["docker", "compose", "up", "-d"], "Failed to restart Docker containers")
+    # Pull Docker images
+    if not run_command_with_output(
+        ["docker", "compose", "pull"],
+        "Pulling Docker images"
+    ):
+        return False, "Failed to pull Docker images", error_log
 
-    update_status = check_git_updates()
+    # Checkout target version
+    if not run_command_with_output(
+        ["git", "checkout", target_version, "--force"],
+        f"Checking out version {target_version}"
+    ):
+        return False, f"Failed to checkout version {target_version}", error_log
 
-    if (
-        update_status["updates_available"] is False
-        and update_status["latest_local_tag"] == update_status["latest_remote_tag"]
+    # Restart Docker containers
+    if not run_command_with_output(
+        ["docker", "compose", "up", "-d", "--remove-orphans"],
+        "Restarting Docker containers"
     ):
-        logger.info("Software update completed successfully")
-        return True, None, None
-    else:
-        logger.error("Software update incomplete")
-        return False, "Update incomplete", error_log
+        return False, "Failed to restart Docker containers", error_log
+
+    log("✓ Software update completed successfully!")
+    log(f"System is now running version: {target_version}")
+
+    return True, None, None

+ 21 - 3
static/js/base.js

@@ -628,15 +628,21 @@ function setupPlayerPreviewModalEvents() {
 async function togglePauseResume() {
     const pauseButton = document.getElementById('modal-pause-button');
     if (!pauseButton) return;
-    
+
     try {
         const pauseIcon = pauseButton.querySelector('.material-icons');
         const isCurrentlyPaused = pauseIcon.textContent === 'play_arrow';
-        
+
+        // Show immediate feedback
+        showStatusMessage(isCurrentlyPaused ? 'Resuming...' : 'Pausing...', 'info');
+
         const endpoint = isCurrentlyPaused ? '/resume_execution' : '/pause_execution';
         const response = await fetch(endpoint, { method: 'POST' });
-        
+
         if (!response.ok) throw new Error(`Failed to ${isCurrentlyPaused ? 'resume' : 'pause'}`);
+
+        // Show success message
+        showStatusMessage(isCurrentlyPaused ? 'Pattern resumed' : 'Pattern paused', 'success');
     } catch (error) {
         console.error('Error toggling pause:', error);
         showStatusMessage('Failed to pause/resume pattern', 'error');
@@ -659,8 +665,14 @@ function setupModalControls() {
     // Skip button click handler
     skipButton.addEventListener('click', async () => {
         try {
+            // Show immediate feedback
+            showStatusMessage('Skipping to next pattern...', 'info');
+
             const response = await fetch('/skip_pattern', { method: 'POST' });
             if (!response.ok) throw new Error('Failed to skip pattern');
+
+            // Show success message
+            showStatusMessage('Skipped to next pattern', 'success');
         } catch (error) {
             console.error('Error skipping pattern:', error);
             showStatusMessage('Failed to skip pattern', 'error');
@@ -670,9 +682,15 @@ function setupModalControls() {
     // Stop button click handler
     stopButton.addEventListener('click', async () => {
         try {
+            // Show immediate feedback
+            showStatusMessage('Stopping...', 'info');
+
             const response = await fetch('/stop_execution', { method: 'POST' });
             if (!response.ok) throw new Error('Failed to stop pattern');
             else {
+                // Show success message
+                showStatusMessage('Pattern stopped', 'success');
+
                 // Hide modal when stopping
                 const modal = document.getElementById('playerPreviewModal');
                 if (modal) modal.classList.add('hidden');

+ 184 - 25
static/js/settings.js

@@ -532,36 +532,195 @@ function setupEventListeners() {
         });
     }
 
-    // Update software
+    // Fetch and populate available versions
+    async function loadAvailableVersions() {
+        try {
+            const response = await fetch('/api/versions');
+            const data = await response.json();
+
+            if (data.success && data.versions) {
+                const versionSelect = document.getElementById('versionSelect');
+                if (versionSelect) {
+                    // Clear existing options except "Latest"
+                    versionSelect.innerHTML = '<option value="latest">Latest Version</option>';
+
+                    // Add optgroup for tags
+                    if (data.versions.tags && data.versions.tags.length > 0) {
+                        const tagsOptgroup = document.createElement('optgroup');
+                        tagsOptgroup.label = 'Tags (Releases)';
+                        data.versions.tags.forEach(tag => {
+                            const option = document.createElement('option');
+                            option.value = tag;
+                            option.textContent = tag;
+                            tagsOptgroup.appendChild(option);
+                        });
+                        versionSelect.appendChild(tagsOptgroup);
+                    }
+
+                    // Add optgroup for branches
+                    if (data.versions.branches && data.versions.branches.length > 0) {
+                        const branchesOptgroup = document.createElement('optgroup');
+                        branchesOptgroup.label = 'Branches';
+                        data.versions.branches.forEach(branch => {
+                            const option = document.createElement('option');
+                            option.value = branch;
+                            option.textContent = branch;
+                            branchesOptgroup.appendChild(option);
+                        });
+                        versionSelect.appendChild(branchesOptgroup);
+                    }
+
+                    // Enable update button
+                    const updateButton = document.getElementById('updateSoftware');
+                    if (updateButton) {
+                        updateButton.disabled = false;
+                        updateButton.classList.remove('bg-gray-400');
+                        updateButton.classList.add('bg-sky-600', 'hover:bg-sky-700');
+                    }
+                }
+            }
+        } catch (error) {
+            console.error('Error loading versions:', error);
+            showStatusMessage('Failed to load available versions', 'error');
+        }
+    }
+
+    // Load versions on page load
+    loadAvailableVersions();
+
+    // Update confirmation modal logic
+    let updateWebSocket = null;
+
+    function showUpdateConfirmModal() {
+        const modal = document.getElementById('updateConfirmModal');
+        const versionSelect = document.getElementById('versionSelect');
+        const targetVersionDisplay = document.getElementById('targetVersionDisplay');
+
+        if (modal && versionSelect && targetVersionDisplay) {
+            const selectedVersion = versionSelect.value;
+            targetVersionDisplay.textContent = selectedVersion;
+            modal.classList.remove('hidden');
+        }
+    }
+
+    function hideUpdateConfirmModal() {
+        const modal = document.getElementById('updateConfirmModal');
+        if (modal) {
+            modal.classList.add('hidden');
+        }
+        // Clean up WebSocket if exists
+        if (updateWebSocket) {
+            updateWebSocket.close();
+            updateWebSocket = null;
+        }
+    }
+
+    function appendUpdateLog(message) {
+        const logContainer = document.getElementById('updateProgressLog');
+        if (logContainer) {
+            const logLine = document.createElement('div');
+            logLine.textContent = message;
+            logContainer.appendChild(logLine);
+            // Auto-scroll to bottom
+            logContainer.parentElement.scrollTop = logContainer.parentElement.scrollHeight;
+        }
+    }
+
+    function startUpdateProcess() {
+        const versionSelect = document.getElementById('versionSelect');
+        const progressContainer = document.getElementById('updateProgressContainer');
+        const modalActions = document.getElementById('updateModalActions');
+        const completeActions = document.getElementById('updateCompleteActions');
+        const selectedVersion = versionSelect ? versionSelect.value : 'latest';
+
+        // Show progress container and hide action buttons
+        if (progressContainer) progressContainer.classList.remove('hidden');
+        if (modalActions) modalActions.classList.add('hidden');
+
+        // Connect to WebSocket for live logs
+        const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+        updateWebSocket = new WebSocket(`${wsProtocol}//${window.location.host}/ws/update-progress`);
+
+        updateWebSocket.onopen = () => {
+            console.log('Update WebSocket connected');
+            appendUpdateLog('Connected to update stream...');
+
+            // Trigger the update
+            fetch('/api/update_to_version', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ version: selectedVersion })
+            })
+            .then(response => response.json())
+            .then(data => {
+                if (!data.success) {
+                    appendUpdateLog(`ERROR: ${data.message || 'Failed to start update'}`);
+                }
+            })
+            .catch(error => {
+                appendUpdateLog(`ERROR: ${error.message}`);
+            });
+        };
+
+        updateWebSocket.onmessage = (event) => {
+            const data = JSON.parse(event.data);
+            if (data.type === 'log') {
+                appendUpdateLog(data.message);
+
+                // Check for completion
+                if (data.message.includes('Update completed successfully') ||
+                    data.message.includes('Update failed')) {
+                    if (completeActions) completeActions.classList.remove('hidden');
+                }
+            }
+        };
+
+        updateWebSocket.onerror = (error) => {
+            console.error('WebSocket error:', error);
+            appendUpdateLog('ERROR: WebSocket connection error');
+        };
+
+        updateWebSocket.onclose = () => {
+            console.log('Update WebSocket closed');
+        };
+    }
+
+    // Update software button - show confirmation modal
     const updateSoftware = document.getElementById('updateSoftware');
     if (updateSoftware) {
-        updateSoftware.addEventListener('click', async () => {
+        updateSoftware.addEventListener('click', () => {
             if (updateSoftware.disabled) {
                 return;
             }
-            
-            try {
-                const response = await fetch('/api/update', {
-                    method: 'POST'
-                });
-                const data = await response.json();
-                
-                if (data.success) {
-                    showStatusMessage('Software update started successfully', 'success');
-                } else if (data.manual_update_url) {
-                    // Show modal with manual update instructions, but use wiki link
-                    const wikiData = {
-                        ...data,
-                        manual_update_url: 'https://github.com/tuanchris/dune-weaver/wiki/Updating-software'
-                    };
-                    showUpdateInstructionsModal(wikiData);
-                } else {
-                    showStatusMessage(data.message || 'No updates available', 'info');
-                }
-            } catch (error) {
-                logMessage(`Error updating software: ${error.message}`, LOG_TYPE.ERROR);
-                showStatusMessage('Failed to check for updates', 'error');
-            }
+            showUpdateConfirmModal();
+        });
+    }
+
+    // Modal action buttons
+    const closeUpdateModal = document.getElementById('closeUpdateModal');
+    const cancelUpdate = document.getElementById('cancelUpdate');
+    const confirmUpdate = document.getElementById('confirmUpdate');
+    const closeUpdateComplete = document.getElementById('closeUpdateComplete');
+
+    if (closeUpdateModal) {
+        closeUpdateModal.addEventListener('click', hideUpdateConfirmModal);
+    }
+
+    if (cancelUpdate) {
+        cancelUpdate.addEventListener('click', hideUpdateConfirmModal);
+    }
+
+    if (confirmUpdate) {
+        confirmUpdate.addEventListener('click', () => {
+            startUpdateProcess();
+        });
+    }
+
+    if (closeUpdateComplete) {
+        closeUpdateComplete.addEventListener('click', () => {
+            hideUpdateConfirmModal();
+            // Optionally reload the page to show new version
+            setTimeout(() => window.location.reload(), 1000);
         });
     }
 

+ 89 - 3
templates/settings.html

@@ -833,17 +833,103 @@ input:checked + .slider:before {
           </p>
           <p id="latestVersionText" class="text-slate-500 text-sm font-normal leading-normal">Checking...</p>
         </div>
+      </div>
+      <div class="px-6 py-5 space-y-4">
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal">Select Version</span>
+          <select
+            id="versionSelect"
+            class="form-select flex-1 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-medium leading-normal transition-colors"
+          >
+            <option value="latest">Latest Version</option>
+          </select>
+          <p class="text-xs text-slate-500 mt-1">
+            Choose a specific version to update to, or select "Latest Version" for the newest release.
+          </p>
+        </label>
         <button
           id="updateSoftware"
-          class="flex items-center justify-center gap-1.5 min-w-[84px] cursor-pointer rounded-lg h-9 px-3 bg-gray-400 text-white text-xs font-medium leading-normal tracking-[0.015em] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+          class="flex items-center justify-center gap-2 w-full sm:w-auto cursor-pointer rounded-lg h-10 px-4 bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium leading-normal tracking-[0.015em] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
           disabled
         >
-          <span id="updateIcon" class="material-icons text-base">download</span>
-          <span id="updateText" class="truncate">Update</span>
+          <span id="updateIcon" class="material-icons text-lg">download</span>
+          <span id="updateText" class="truncate">Update Software</span>
         </button>
       </div>
     </div>
   </section>
+
+  <!-- Update Confirmation Modal -->
+  <div id="updateConfirmModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
+    <div class="bg-white dark:bg-neutral-900 rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col">
+      <!-- Modal Header -->
+      <div class="px-6 py-4 border-b border-slate-200 dark:border-neutral-700 flex items-center justify-between">
+        <h3 class="text-xl font-semibold text-slate-900 dark:text-white">Confirm Software Update</h3>
+        <button id="closeUpdateModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
+          <span class="material-icons">close</span>
+        </button>
+      </div>
+
+      <!-- Modal Body -->
+      <div class="px-6 py-4 flex-1 overflow-y-auto space-y-4">
+        <div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
+          <div class="flex items-start gap-2">
+            <span class="material-icons text-amber-600 dark:text-amber-500 text-xl">warning</span>
+            <div class="text-sm text-amber-800 dark:text-amber-300">
+              <p class="font-medium">Important:</p>
+              <ul class="mt-2 space-y-1 list-disc list-inside">
+                <li>The system will restart during the update process</li>
+                <li>This may take several minutes</li>
+                <li>Do not close this window or refresh the page</li>
+              </ul>
+            </div>
+          </div>
+        </div>
+
+        <div class="space-y-2">
+          <p class="text-sm text-slate-700 dark:text-slate-300">
+            <span class="font-medium">Target version:</span>
+            <span id="targetVersionDisplay" class="text-sky-600 dark:text-sky-400 font-mono">latest</span>
+          </p>
+        </div>
+
+        <!-- Progress Log Container -->
+        <div id="updateProgressContainer" class="hidden space-y-2">
+          <label class="text-sm font-medium text-slate-700 dark:text-slate-300">Update Progress:</label>
+          <div class="bg-slate-900 dark:bg-black rounded-lg p-4 max-h-96 overflow-y-auto font-mono text-xs text-green-400">
+            <div id="updateProgressLog"></div>
+          </div>
+        </div>
+      </div>
+
+      <!-- Modal Footer -->
+      <div id="updateModalActions" class="px-6 py-4 border-t border-slate-200 dark:border-neutral-700 flex justify-end gap-3">
+        <button
+          id="cancelUpdate"
+          class="px-4 py-2 rounded-lg border border-slate-300 dark:border-neutral-600 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-neutral-800 text-sm font-medium transition-colors"
+        >
+          Cancel
+        </button>
+        <button
+          id="confirmUpdate"
+          class="px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium transition-colors flex items-center gap-2"
+        >
+          <span class="material-icons text-lg">download</span>
+          <span>Start Update</span>
+        </button>
+      </div>
+
+      <!-- Update Complete Actions -->
+      <div id="updateCompleteActions" class="px-6 py-4 border-t border-slate-200 dark:border-neutral-700 flex justify-end gap-3 hidden">
+        <button
+          id="closeUpdateComplete"
+          class="px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium transition-colors"
+        >
+          Close
+        </button>
+      </div>
+    </div>
+  </div>
 </div>
 {% endblock %} {% block scripts %}
 <script src="/static/js/settings.js"></script>