Browse Source

Enhanced overlay manager

Nicole Portas 1 month ago
parent
commit
209668bc5e

+ 4 - 1
docker-compose.yml

@@ -45,8 +45,11 @@ services:
   overlay-manager:
   overlay-manager:
     build: ./overlay_manager
     build: ./overlay_manager
     restart: always
     restart: always
+    pid: "service:builder"
+    environment:
+      CBS_BUILD_TIMEOUT_SEC: ${CBS_BUILD_TIMEOUT_SEC:-900} # Matches your builder timeout
     volumes:
     volumes:
       - ./custom_overlays:/srv:rw
       - ./custom_overlays:/srv:rw
-      - ./overlay_db:/app/db   # <-- We mount a whole folder here now
+      - ./overlay_db:/app/db
     ports:
     ports:
       - "0.0.0.0:11081:80"
       - "0.0.0.0:11081:80"

+ 0 - 1
overlay_manager/Dockerfile

@@ -6,6 +6,5 @@ WORKDIR /app
 RUN pip install --no-cache-dir fastapi uvicorn jinja2 python-multipart
 RUN pip install --no-cache-dir fastapi uvicorn jinja2 python-multipart
 
 
 COPY . .
 COPY . .
-
 # Run the app on port 80
 # Run the app on port 80
 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]
 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"]

+ 167 - 27
overlay_manager/main.py

@@ -3,6 +3,7 @@ import sqlite3
 import shutil
 import shutil
 import tempfile
 import tempfile
 import logging
 import logging
+import hashlib
 from urllib.parse import quote
 from urllib.parse import quote
 from fastapi import FastAPI, Request, File, UploadFile, Form, HTTPException
 from fastapi import FastAPI, Request, File, UploadFile, Form, HTTPException
 from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
 from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
@@ -47,7 +48,7 @@ def init_db():
             )
             )
         """)
         """)
         
         
-        # Auto-migrate: Add description and is_readonly columns if they don't exist
+        # Auto-migrate: Add description, is_readonly, and password columns if they don't exist
         cursor = conn.cursor()
         cursor = conn.cursor()
         cursor.execute("PRAGMA table_info(profiles)")
         cursor.execute("PRAGMA table_info(profiles)")
         columns = [info[1] for info in cursor.fetchall()]
         columns = [info[1] for info in cursor.fetchall()]
@@ -55,6 +56,8 @@ def init_db():
             cursor.execute("ALTER TABLE profiles ADD COLUMN description TEXT DEFAULT ''")
             cursor.execute("ALTER TABLE profiles ADD COLUMN description TEXT DEFAULT ''")
         if "is_readonly" not in columns:
         if "is_readonly" not in columns:
             cursor.execute("ALTER TABLE profiles ADD COLUMN is_readonly BOOLEAN NOT NULL DEFAULT 0")
             cursor.execute("ALTER TABLE profiles ADD COLUMN is_readonly BOOLEAN NOT NULL DEFAULT 0")
+        if "password" not in columns:
+            cursor.execute("ALTER TABLE profiles ADD COLUMN password TEXT DEFAULT NULL")
         
         
         # Create default profiles if the DB is empty
         # Create default profiles if the DB is empty
         cursor.execute("SELECT count(*) FROM profiles")
         cursor.execute("SELECT count(*) FROM profiles")
@@ -74,18 +77,75 @@ def get_db_connection():
     conn.row_factory = sqlite3.Row
     conn.row_factory = sqlite3.Row
     return conn
     return conn
 
 
+def hash_password(password: str) -> str:
+    return hashlib.sha256(password.encode('utf-8')).hexdigest()
+
+def is_compiling() -> bool:
+    """
+    Dynamically checks if a build is happening by scanning active processes.
+    Includes a timeout check to prevent infinite lockups if the builder hangs.
+    """
+    try:
+        # Match the timeout set in docker-compose (default 900 seconds / 15 mins)
+        timeout_sec = int(os.environ.get("CBS_BUILD_TIMEOUT_SEC", 900))
+        
+        # Get the overall system uptime to calculate process age
+        with open('/proc/uptime', 'r') as f:
+            sys_uptime = float(f.read().split()[0])
+            
+        ticks_per_sec = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
+
+        for pid in os.listdir('/proc'):
+            if pid.isdigit():
+                try:
+                    with open(f'/proc/{pid}/cmdline', 'r') as f:
+                        cmdline = f.read().replace('\x00', ' ')
+                        
+                    # 'waf' is the build system for ArduPilot
+                    if 'waf' in cmdline:
+                        # Read the process stats to find out when it started
+                        with open(f'/proc/{pid}/stat', 'r') as f:
+                            stat_fields = f.read().split()
+                            # Field 22 (index 21) is starttime in clock ticks
+                            starttime_ticks = int(stat_fields[21])
+                            starttime_sec = starttime_ticks / ticks_per_sec
+                            
+                        process_uptime = sys_uptime - starttime_sec
+                        
+                        # If the waf process has been running longer than the timeout, it's hung
+                        if process_uptime > timeout_sec:
+                            logger.warning(f"Ignoring stale 'waf' process (PID {pid}): running for {process_uptime:.1f}s (exceeds {timeout_sec}s timeout)")
+                            continue
+                            
+                        return True
+                except (IOError, FileNotFoundError, IndexError, ValueError):
+                    continue
+    except Exception as e:
+        logger.debug(f"Process scan failed/unavailable: {e}")
+        
+    return False
+
 @app.get("/", response_class=HTMLResponse)
 @app.get("/", response_class=HTMLResponse)
 async def index(request: Request, success_msg: str = None, error_msg: str = None):
 async def index(request: Request, success_msg: str = None, error_msg: str = None):
     conn = get_db_connection()
     conn = get_db_connection()
     
     
-    profiles = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
-    active_profile = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
+    profiles_raw = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
+    profiles = []
+    for p in profiles_raw:
+        p_dict = dict(p)
+        p_dict['is_locked'] = bool(p_dict.get('password'))
+        profiles.append(p_dict)
+        
+    active_profile_raw = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
+    active_profile = dict(active_profile_raw) if active_profile_raw else None
+    if active_profile:
+        active_profile['is_locked'] = bool(active_profile.get('password'))
     
     
     files = []
     files = []
     dirs = set()
     dirs = set()
     
     
     if active_profile:
     if active_profile:
-        files = conn.execute("SELECT * FROM files WHERE profile_id = ? ORDER BY filepath", (active_profile['id'],)).fetchall()
+        files = [dict(f) for f in conn.execute("SELECT * FROM files WHERE profile_id = ? ORDER BY filepath", (active_profile['id'],)).fetchall()]
         for f in files:
         for f in files:
             dirname = os.path.dirname(f['filepath'])
             dirname = os.path.dirname(f['filepath'])
             if dirname:
             if dirname:
@@ -100,7 +160,8 @@ async def index(request: Request, success_msg: str = None, error_msg: str = None
         "files": files,
         "files": files,
         "dirs": sorted(list(dirs)),
         "dirs": sorted(list(dirs)),
         "success_msg": success_msg,
         "success_msg": success_msg,
-        "error_msg": error_msg
+        "error_msg": error_msg,
+        "is_compiling": is_compiling()
     })
     })
 
 
 @app.post("/profile/create")
 @app.post("/profile/create")
@@ -122,9 +183,12 @@ async def create_profile(name: str = Form(...)):
 async def rename_profile(profile_id: int = Form(...), new_name: str = Form(...)):
 async def rename_profile(profile_id: int = Form(...), new_name: str = Form(...)):
     try:
     try:
         with get_db_connection() as conn:
         with get_db_connection() as conn:
-            prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
-            if prof and prof['is_readonly']:
-                return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a read-only profile.", status_code=303)
+            prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if prof:
+                if prof['is_readonly']:
+                    return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a read-only profile.", status_code=303)
+                if prof['password']:
+                    return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot rename a locked profile. Unlock it first.", status_code=303)
                 
                 
             conn.execute("UPDATE profiles SET name = ? WHERE id = ?", (new_name.strip(), profile_id))
             conn.execute("UPDATE profiles SET name = ? WHERE id = ?", (new_name.strip(), profile_id))
             conn.commit()
             conn.commit()
@@ -141,9 +205,12 @@ async def rename_profile(profile_id: int = Form(...), new_name: str = Form(...))
 async def annotate_profile(profile_id: int = Form(...), description: str = Form("")):
 async def annotate_profile(profile_id: int = Form(...), description: str = Form("")):
     try:
     try:
         with get_db_connection() as conn:
         with get_db_connection() as conn:
-            prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
-            if prof and prof['is_readonly']:
-                return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a read-only profile.", status_code=303)
+            prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if prof:
+                if prof['is_readonly']:
+                    return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a read-only profile.", status_code=303)
+                if prof['password']:
+                    return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot edit notes on a locked profile. Unlock it first.", status_code=303)
 
 
             conn.execute("UPDATE profiles SET description = ? WHERE id = ?", (description, profile_id))
             conn.execute("UPDATE profiles SET description = ? WHERE id = ?", (description, profile_id))
             conn.commit()
             conn.commit()
@@ -177,8 +244,8 @@ async def duplicate_profile(profile_id: int = Form(...)):
             
             
             cursor.execute("UPDATE profiles SET is_active = 0")
             cursor.execute("UPDATE profiles SET is_active = 0")
             
             
-            # Duplicates are NEVER read-only, even if cloned from a read-only profile
-            cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES (?, ?, 1, 0)", (new_name, desc))
+            # Duplicates are NEVER read-only, and NEVER locked by default
+            cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly, password) VALUES (?, ?, 1, 0, NULL)", (new_name, desc))
             new_profile_id = cursor.lastrowid
             new_profile_id = cursor.lastrowid
             
             
             files = cursor.execute("SELECT filepath, content FROM files WHERE profile_id = ?", (profile_id,)).fetchall()
             files = cursor.execute("SELECT filepath, content FROM files WHERE profile_id = ?", (profile_id,)).fetchall()
@@ -209,15 +276,18 @@ async def activate_profile(profile_id: int = Form(...)):
 async def delete_profile(profile_id: int = Form(...)):
 async def delete_profile(profile_id: int = Form(...)):
     try:
     try:
         with get_db_connection() as conn:
         with get_db_connection() as conn:
-            prof = conn.execute("SELECT is_readonly, is_active FROM profiles WHERE id = ?").fetchone()
+            prof = conn.execute("SELECT is_readonly, is_active, password FROM profiles WHERE id = ?").fetchone()
             
             
-            if prof and prof['is_readonly']:
-                msg = quote("Cannot delete a read-only profile.")
-                return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
-                
-            if prof and prof['is_active']:
-                msg = quote("Cannot delete the currently active profile. Switch to another first.")
-                return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+            if prof:
+                if prof['is_readonly']:
+                    msg = quote("Cannot delete a read-only profile.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+                if prof['password']:
+                    msg = quote("Cannot delete a locked profile. Unlock it first.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+                if prof['is_active']:
+                    msg = quote("Cannot delete the currently active profile. Switch to another first.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
                 
                 
             conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
             conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
             conn.commit()
             conn.commit()
@@ -227,8 +297,50 @@ async def delete_profile(profile_id: int = Form(...)):
         logger.error(f"Profile deletion failed: {e}")
         logger.error(f"Profile deletion failed: {e}")
         return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to delete profile.", status_code=303)
         return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to delete profile.", status_code=303)
 
 
+@app.post("/profile/lock")
+async def lock_profile(profile_id: int = Form(...), password: str = Form(...)):
+    try:
+        if not password.strip():
+            return RedirectResponse(url=f"/patch-manager/?error_msg=Password cannot be empty.", status_code=303)
+            
+        hashed_pw = hash_password(password.strip())
+        with get_db_connection() as conn:
+            prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if prof and prof['is_readonly']:
+                return RedirectResponse(url=f"/patch-manager/?error_msg=Cannot lock a system read-only profile.", status_code=303)
+                
+            conn.execute("UPDATE profiles SET password = ? WHERE id = ?", (hashed_pw, profile_id))
+            conn.commit()
+        msg = quote("Profile locked successfully.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Profile lock failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to lock profile.", status_code=303)
+
+@app.post("/profile/unlock")
+async def unlock_profile(profile_id: int = Form(...), password: str = Form(...)):
+    try:
+        hashed_pw = hash_password(password.strip())
+        with get_db_connection() as conn:
+            prof = conn.execute("SELECT password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if not prof or prof['password'] != hashed_pw:
+                return RedirectResponse(url=f"/patch-manager/?error_msg=Incorrect password.", status_code=303)
+                
+            conn.execute("UPDATE profiles SET password = NULL WHERE id = ?", (profile_id,))
+            conn.commit()
+        msg = quote("Profile unlocked successfully.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Profile unlock failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to unlock profile.", status_code=303)
+
 @app.post("/deploy")
 @app.post("/deploy")
 async def deploy_active_profile():
 async def deploy_active_profile():
+    # Double-check right before deploying to prevent race conditions
+    if is_compiling():
+        msg = quote("Deployment blocked: A build is currently in progress.")
+        return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+
     try:
     try:
         with get_db_connection() as conn:
         with get_db_connection() as conn:
             active = conn.execute("SELECT id, name FROM profiles WHERE is_active = 1").fetchone()
             active = conn.execute("SELECT id, name FROM profiles WHERE is_active = 1").fetchone()
@@ -270,10 +382,14 @@ async def upload_file(
 ):
 ):
     try:
     try:
         with get_db_connection() as conn:
         with get_db_connection() as conn:
-            prof = conn.execute("SELECT is_readonly FROM profiles WHERE id = ?", (profile_id,)).fetchone()
-            if prof and prof['is_readonly']:
-                msg = quote("Cannot upload files to a read-only profile.")
-                return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+            prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if prof:
+                if prof['is_readonly']:
+                    msg = quote("Cannot upload files to a read-only profile.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+                if prof['password']:
+                    msg = quote("Cannot upload files to a locked profile.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
 
 
             target_path = new_path.strip() if new_path.strip() else existing_path.strip()
             target_path = new_path.strip() if new_path.strip() else existing_path.strip()
             clean_path = target_path.strip("/")
             clean_path = target_path.strip("/")
@@ -302,6 +418,13 @@ async def upload_file(
 async def delete_file(file_id: int = Form(...)):
 async def delete_file(file_id: int = Form(...)):
     try:
     try:
         with get_db_connection() as conn:
         with get_db_connection() as conn:
+            file_record = conn.execute("SELECT profile_id FROM files WHERE id = ?", (file_id,)).fetchone()
+            if file_record:
+                prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (file_record['profile_id'],)).fetchone()
+                if prof and (prof['is_readonly'] or prof['password']):
+                    msg = quote("Cannot delete file from a locked or read-only profile.")
+                    return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+
             conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
             conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
             conn.commit()
             conn.commit()
         return RedirectResponse(url=f"/patch-manager/", status_code=303)
         return RedirectResponse(url=f"/patch-manager/", status_code=303)
@@ -314,8 +437,18 @@ async def edit_file(request: Request, file_id: int, error_msg: str = None):
     try:
     try:
         with get_db_connection() as conn:
         with get_db_connection() as conn:
             file_record = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
             file_record = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
-            active_profile = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
-            profiles = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
+            
+            active_profile_raw = conn.execute("SELECT * FROM profiles WHERE is_active = 1").fetchone()
+            active_profile = dict(active_profile_raw) if active_profile_raw else None
+            if active_profile:
+                active_profile['is_locked'] = bool(active_profile.get('password'))
+                
+            profiles_raw = conn.execute("SELECT * FROM profiles ORDER BY name").fetchall()
+            profiles = []
+            for p in profiles_raw:
+                p_dict = dict(p)
+                p_dict['is_locked'] = bool(p_dict.get('password'))
+                profiles.append(p_dict)
             
             
         if not file_record:
         if not file_record:
             msg = quote("File not found in database.")
             msg = quote("File not found in database.")
@@ -346,6 +479,13 @@ async def save_file(file_id: int = Form(...), content: str = Form(...)):
     try:
     try:
         content_bytes = content.encode('utf-8')
         content_bytes = content.encode('utf-8')
         with get_db_connection() as conn:
         with get_db_connection() as conn:
+            file_record = conn.execute("SELECT profile_id FROM files WHERE id = ?", (file_id,)).fetchone()
+            if file_record:
+                prof = conn.execute("SELECT is_readonly, password FROM profiles WHERE id = ?", (file_record['profile_id'],)).fetchone()
+                if prof and (prof['is_readonly'] or prof['password']):
+                    msg = quote("Cannot modify files in a locked or read-only profile.")
+                    return RedirectResponse(url=f"/patch-manager/edit?file_id={file_id}&error_msg={msg}", status_code=303)
+
             conn.execute("UPDATE files SET content = ? WHERE id = ?", (content_bytes, file_id))
             conn.execute("UPDATE files SET content = ? WHERE id = ?", (content_bytes, file_id))
             conn.commit()
             conn.commit()
             
             

+ 23 - 3
overlay_manager/templates/edit.html

@@ -76,6 +76,11 @@
                             <div class="list-group-item d-flex justify-content-between align-items-center profile-list-item {% if p.id == active_profile.id %}active{% endif %}">
                             <div class="list-group-item d-flex justify-content-between align-items-center profile-list-item {% if p.id == active_profile.id %}active{% endif %}">
                                 <div class="w-100 fw-bold text-muted {% if p.id == active_profile.id %}text-white{% endif %}">
                                 <div class="w-100 fw-bold text-muted {% if p.id == active_profile.id %}text-white{% endif %}">
                                     {{ p.name }}
                                     {{ p.name }}
+                                    {% if p.is_readonly %}
+                                        <i class="bi bi-shield-lock-fill ms-1" title="Read-Only System Profile"></i>
+                                    {% elif p.is_locked %}
+                                        <i class="bi bi-lock-fill ms-1" title="User Locked"></i>
+                                    {% endif %}
                                     {% if p.id == active_profile.id %}<i class="bi bi-check-circle-fill ms-2"></i>{% endif %}
                                     {% if p.id == active_profile.id %}<i class="bi bi-check-circle-fill ms-2"></i>{% endif %}
                                 </div>
                                 </div>
                             </div>
                             </div>
@@ -97,15 +102,23 @@
                         </div>
                         </div>
                         
                         
                         <div class="card-body p-0">
                         <div class="card-body p-0">
+                            {% if active_profile.is_readonly or active_profile.is_locked %}
+                            <div class="alert alert-warning m-3 shadow-sm border-0">
+                                <i class="bi bi-lock-fill me-2"></i> This profile is locked or read-only. You can view the file but cannot save changes.
+                            </div>
+                            {% endif %}
+                            
                             <form action="edit" method="post" id="editForm">
                             <form action="edit" method="post" id="editForm">
                                 <input type="hidden" name="file_id" value="{{ file_id }}">
                                 <input type="hidden" name="file_id" value="{{ file_id }}">
                                 
                                 
                                 <textarea id="code-editor" name="content">{{ content }}</textarea>
                                 <textarea id="code-editor" name="content">{{ content }}</textarea>
 
 
+                                {% if not active_profile.is_readonly and not active_profile.is_locked %}
                                 <div class="p-3 bg-light text-end rounded-bottom border-top">
                                 <div class="p-3 bg-light text-end rounded-bottom border-top">
                                     <a href="./" class="btn btn-secondary me-2">Cancel</a>
                                     <a href="./" class="btn btn-secondary me-2">Cancel</a>
                                     <button type="submit" class="btn btn-success fw-bold"><i class="bi bi-save2-fill me-2"></i> Save Changes to DB</button>
                                     <button type="submit" class="btn btn-success fw-bold"><i class="bi bi-save2-fill me-2"></i> Save Changes to DB</button>
                                 </div>
                                 </div>
+                                {% endif %}
                             </form>
                             </form>
                         </div>
                         </div>
                     </div>
                     </div>
@@ -131,17 +144,24 @@
     <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/mode/python/python.min.js"></script>
     <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/mode/python/python.min.js"></script>
     
     
     <script>
     <script>
+        var isLocked = {{ 'true' if active_profile.is_readonly or active_profile.is_locked else 'false' }};
         var editor = CodeMirror.fromTextArea(document.getElementById("code-editor"), {
         var editor = CodeMirror.fromTextArea(document.getElementById("code-editor"), {
             lineNumbers: true,
             lineNumbers: true,
             mode: "text/x-c++src", 
             mode: "text/x-c++src", 
             theme: "monokai",
             theme: "monokai",
             matchBrackets: true,
             matchBrackets: true,
             indentUnit: 4,
             indentUnit: 4,
-            indentWithTabs: false
+            indentWithTabs: false,
+            readOnly: isLocked ? "nocursor" : false
         });
         });
         
         
-        document.getElementById('editForm').addEventListener('submit', function() {
-            editor.save();
+        document.getElementById('editForm').addEventListener('submit', function(e) {
+            if (isLocked) {
+                e.preventDefault();
+                alert('Cannot save. The profile is currently locked.');
+            } else {
+                editor.save();
+            }
         });
         });
     </script>
     </script>
 </body>
 </body>

+ 111 - 13
overlay_manager/templates/index.html

@@ -66,7 +66,7 @@
                 <div class="col-md-3">
                 <div class="col-md-3">
                     <div class="card mb-4">
                     <div class="card mb-4">
                         <div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
                         <div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
-                            <h6 class="mb-0 fw-bold text-dark"><i class="bi bi-diagram-3-fill me-2 text-primary"></i>Profiles / Loadouts</h6>
+                            <h6 class="mb-0 fw-bold text-dark"><i class="bi bi-diagram-3-fill me-2 text-primary"></i>Overlay Profiles</h6>
                         </div>
                         </div>
                         <div class="list-group list-group-flush">
                         <div class="list-group list-group-flush">
                             {% for p in profiles %}
                             {% for p in profiles %}
@@ -75,7 +75,11 @@
                                     <input type="hidden" name="profile_id" value="{{ p.id }}">
                                     <input type="hidden" name="profile_id" value="{{ p.id }}">
                                     <div onclick="this.parentNode.submit();" class="w-100 fw-bold">
                                     <div onclick="this.parentNode.submit();" class="w-100 fw-bold">
                                         {{ p.name }}
                                         {{ p.name }}
-                                        {% if p.is_readonly %}<i class="bi bi-lock-fill ms-1" title="Read-Only"></i>{% endif %}
+                                        {% if p.is_readonly %}
+                                            <i class="bi bi-shield-lock-fill ms-1" title="Read-Only System Profile"></i>
+                                        {% elif p.is_locked %}
+                                            <i class="bi bi-lock-fill text-warning ms-1" title="User Locked"></i>
+                                        {% endif %}
                                         {% if p.is_active %}<i class="bi bi-check-circle-fill ms-2"></i>{% endif %}
                                         {% if p.is_active %}<i class="bi bi-check-circle-fill ms-2"></i>{% endif %}
                                     </div>
                                     </div>
                                 </form>
                                 </form>
@@ -85,20 +89,33 @@
                                         <i class="bi bi-three-dots-vertical fs-5"></i>
                                         <i class="bi bi-three-dots-vertical fs-5"></i>
                                     </button>
                                     </button>
                                     <ul class="dropdown-menu shadow-sm dropdown-menu-end">
                                     <ul class="dropdown-menu shadow-sm dropdown-menu-end">
-                                        {% if not p.is_readonly %}
-                                        <li>
-                                            <a class="dropdown-item" href="javascript:void(0)" onclick="openRenameModal({{ p.id }}, '{{ p.name|escape }}')">
-                                                <i class="bi bi-pencil-square me-2 text-primary"></i>Rename Profile
-                                            </a>
-                                        </li>
+                                        {% if p.is_locked %}
+                                            <li>
+                                                <a class="dropdown-item" href="javascript:void(0)" onclick="openUnlockModal({{ p.id }}, '{{ p.name|escape }}')">
+                                                    <i class="bi bi-unlock-fill me-2 text-warning"></i>Unlock Profile
+                                                </a>
+                                            </li>
+                                        {% elif not p.is_readonly %}
+                                            <li>
+                                                <a class="dropdown-item" href="javascript:void(0)" onclick="openRenameModal({{ p.id }}, '{{ p.name|escape }}')">
+                                                    <i class="bi bi-pencil-square me-2 text-primary"></i>Rename Profile
+                                                </a>
+                                            </li>
+                                            <li>
+                                                <a class="dropdown-item" href="javascript:void(0)" onclick="openLockModal({{ p.id }}, '{{ p.name|escape }}')">
+                                                    <i class="bi bi-lock-fill me-2 text-warning"></i>Lock Profile
+                                                </a>
+                                            </li>
                                         {% endif %}
                                         {% endif %}
+                                        
                                         <li>
                                         <li>
                                             <form action="/patch-manager/profile/duplicate" method="post" class="m-0 p-0">
                                             <form action="/patch-manager/profile/duplicate" method="post" class="m-0 p-0">
                                                 <input type="hidden" name="profile_id" value="{{ p.id }}">
                                                 <input type="hidden" name="profile_id" value="{{ p.id }}">
                                                 <button type="submit" class="dropdown-item"><i class="bi bi-copy me-2 text-success"></i>Clone Profile</button>
                                                 <button type="submit" class="dropdown-item"><i class="bi bi-copy me-2 text-success"></i>Clone Profile</button>
                                             </form>
                                             </form>
                                         </li>
                                         </li>
-                                        {% if not p.is_active and not p.is_readonly %}
+                                        
+                                        {% if not p.is_active and not p.is_readonly and not p.is_locked %}
                                         <li><hr class="dropdown-divider"></li>
                                         <li><hr class="dropdown-divider"></li>
                                         <li>
                                         <li>
                                             <form action="/patch-manager/profile/delete" method="post" class="m-0 p-0" onsubmit="return confirm('Delete this profile completely?');">
                                             <form action="/patch-manager/profile/delete" method="post" class="m-0 p-0" onsubmit="return confirm('Delete this profile completely?');">
@@ -121,6 +138,17 @@
                         </div>
                         </div>
                     </div>
                     </div>
 
 
+                    {% if is_compiling %}
+                    <div class="card bg-danger text-white shadow border-0">
+                        <div class="card-body text-center p-4">
+                            <div class="spinner-border text-light mb-3" role="status" style="width: 3rem; height: 3rem;">
+                                <span class="visually-hidden">Loading...</span>
+                            </div>
+                            <h5 class="fw-bold">Build in Progress</h5>
+                            <p class="small text-white-75 mb-0">Another user is currently compiling. Deployment is locked to prevent overwriting files. Please wait.</p>
+                        </div>
+                    </div>
+                    {% else %}
                     <div class="card bg-dark text-white shadow">
                     <div class="card bg-dark text-white shadow">
                         <div class="card-body text-center p-4">
                         <div class="card-body text-center p-4">
                             <i class="bi bi-rocket-takeoff-fill display-4 text-warning mb-3 d-block"></i>
                             <i class="bi bi-rocket-takeoff-fill display-4 text-warning mb-3 d-block"></i>
@@ -133,6 +161,7 @@
                             </form>
                             </form>
                         </div>
                         </div>
                     </div>
                     </div>
+                    {% endif %}
                 </div>
                 </div>
 
 
                 <div class="col-md-9">
                 <div class="col-md-9">
@@ -144,8 +173,8 @@
                         <div class="card-body p-3 bg-light">
                         <div class="card-body p-3 bg-light">
                             <form action="/patch-manager/profile/annotate" method="post" class="d-flex flex-column gap-2">
                             <form action="/patch-manager/profile/annotate" method="post" class="d-flex flex-column gap-2">
                                 <input type="hidden" name="profile_id" value="{{ active_profile.id }}">
                                 <input type="hidden" name="profile_id" value="{{ active_profile.id }}">
-                                <textarea class="form-control" name="description" rows="3" {% if active_profile.is_readonly %}disabled{% endif %} placeholder="Add notes about this loadout (e.g., 'Aggressive PID tune, experimental VTX settings')">{{ active_profile.description }}</textarea>
-                                {% if not active_profile.is_readonly %}
+                                <textarea class="form-control" name="description" rows="3" {% if active_profile.is_readonly or active_profile.is_locked %}disabled{% endif %} placeholder="Add notes about this loadout (e.g., 'Aggressive PID tune, experimental VTX settings')">{{ active_profile.description }}</textarea>
+                                {% if not active_profile.is_readonly and not active_profile.is_locked %}
                                 <div class="text-end">
                                 <div class="text-end">
                                     <button type="submit" class="btn btn-warning btn-sm fw-bold"><i class="bi bi-save me-1"></i> Save Notes</button>
                                     <button type="submit" class="btn btn-warning btn-sm fw-bold"><i class="bi bi-save me-1"></i> Save Notes</button>
                                 </div>
                                 </div>
@@ -159,10 +188,14 @@
                             <h6 class="card-title mb-0 fw-bold text-dark"><i class="bi bi-cloud-arrow-up text-success me-2"></i>Upload File to Profile</h6>
                             <h6 class="card-title mb-0 fw-bold text-dark"><i class="bi bi-cloud-arrow-up text-success me-2"></i>Upload File to Profile</h6>
                         </div>
                         </div>
                         
                         
-                        {% if active_profile.is_readonly %}
+                        {% if active_profile.is_readonly or active_profile.is_locked %}
                         <div class="card-body p-4 bg-light text-center text-muted">
                         <div class="card-body p-4 bg-light text-center text-muted">
                             <i class="bi bi-lock-fill fs-3 d-block mb-2"></i>
                             <i class="bi bi-lock-fill fs-3 d-block mb-2"></i>
-                            <p class="mb-0">This is a locked, unmodified base profile. You cannot upload files here.<br><strong>Clone this profile</strong> from the menu on the left to start making changes.</p>
+                            {% if active_profile.is_readonly %}
+                                <p class="mb-0">This is a locked, unmodified base profile. You cannot upload files here.<br><strong>Clone this profile</strong> from the menu on the left to start making changes.</p>
+                            {% else %}
+                                <p class="mb-0">This profile is locked by a user. You cannot upload files here.<br><strong>Unlock this profile</strong> from the menu on the left if you know the password, or <strong>Clone this profile</strong> to make your own copy and start making changes.</p>
+                            {% endif %}
                         </div>
                         </div>
                         {% else %}
                         {% else %}
                         <div class="card-body p-3 bg-light">
                         <div class="card-body p-3 bg-light">
@@ -222,6 +255,8 @@
                                                 <a href="/patch-manager/download?file_id={{ file.id }}" class="btn btn-sm btn-outline-info me-2" title="Download">
                                                 <a href="/patch-manager/download?file_id={{ file.id }}" class="btn btn-sm btn-outline-info me-2" title="Download">
                                                     <i class="bi bi-download"></i>
                                                     <i class="bi bi-download"></i>
                                                 </a>
                                                 </a>
+                                                
+                                                {% if not active_profile.is_readonly and not active_profile.is_locked %}
                                                 <a href="/patch-manager/edit?file_id={{ file.id }}" class="btn btn-sm btn-outline-primary me-2">
                                                 <a href="/patch-manager/edit?file_id={{ file.id }}" class="btn btn-sm btn-outline-primary me-2">
                                                     <i class="bi bi-pencil me-1"></i> Edit
                                                     <i class="bi bi-pencil me-1"></i> Edit
                                                 </a>
                                                 </a>
@@ -231,6 +266,7 @@
                                                         <i class="bi bi-trash"></i>
                                                         <i class="bi bi-trash"></i>
                                                     </button>
                                                     </button>
                                                 </form>
                                                 </form>
+                                                {% endif %}
                                             </td>
                                             </td>
                                         </tr>
                                         </tr>
                                         {% else %}
                                         {% else %}
@@ -276,6 +312,56 @@
         </div>
         </div>
     </div>
     </div>
 
 
+    <div class="modal fade" id="lockModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <form action="/patch-manager/profile/lock" method="post">
+                    <div class="modal-header bg-light">
+                        <h5 class="modal-title fw-bold text-dark"><i class="bi bi-lock-fill me-2 text-warning"></i>Lock Profile</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                    </div>
+                    <div class="modal-body p-4">
+                        <input type="hidden" name="profile_id" id="lock-profile-id">
+                        <p>Locking <strong id="lock-profile-name"></strong> will prevent it from being edited or deleted until the password is provided.</p>
+                        <div class="mb-3">
+                            <label class="form-label text-muted small fw-bold">Set Password</label>
+                            <input type="password" class="form-control" name="password" required>
+                        </div>
+                    </div>
+                    <div class="modal-footer bg-light d-flex justify-content-between">
+                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+                        <button type="submit" class="btn btn-warning fw-bold text-dark"><i class="bi bi-lock-fill me-1"></i>Lock Profile</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
+    <div class="modal fade" id="unlockModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <form action="/patch-manager/profile/unlock" method="post">
+                    <div class="modal-header bg-light">
+                        <h5 class="modal-title fw-bold text-dark"><i class="bi bi-unlock-fill me-2 text-warning"></i>Unlock Profile</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                    </div>
+                    <div class="modal-body p-4">
+                        <input type="hidden" name="profile_id" id="unlock-profile-id">
+                        <p>Enter the password to unlock <strong id="unlock-profile-name"></strong>.</p>
+                        <div class="mb-3">
+                            <label class="form-label text-muted small fw-bold">Password</label>
+                            <input type="password" class="form-control" name="password" required>
+                        </div>
+                    </div>
+                    <div class="modal-footer bg-light d-flex justify-content-between">
+                        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+                        <button type="submit" class="btn btn-warning fw-bold text-dark"><i class="bi bi-unlock-fill me-1"></i>Unlock Profile</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
     <footer class="py-3 bg-dark text-white-50 mt-auto">
     <footer class="py-3 bg-dark text-white-50 mt-auto">
         <div class="container-fluid px-4 d-flex justify-content-between">
         <div class="container-fluid px-4 d-flex justify-content-between">
             <div>
             <div>
@@ -297,6 +383,18 @@
             document.getElementById('rename-new-name').value = profileName;
             document.getElementById('rename-new-name').value = profileName;
             new bootstrap.Modal(document.getElementById('renameModal')).show();
             new bootstrap.Modal(document.getElementById('renameModal')).show();
         }
         }
+
+        function openLockModal(profileId, profileName) {
+            document.getElementById('lock-profile-id').value = profileId;
+            document.getElementById('lock-profile-name').innerText = profileName;
+            new bootstrap.Modal(document.getElementById('lockModal')).show();
+        }
+
+        function openUnlockModal(profileId, profileName) {
+            document.getElementById('unlock-profile-id').value = profileId;
+            document.getElementById('unlock-profile-name').innerText = profileName;
+            new bootstrap.Modal(document.getElementById('unlockModal')).show();
+        }
     </script>
     </script>
 </body>
 </body>
 </html>
 </html>