Nicole 1 місяць тому
батько
коміт
3c1773f791

+ 1 - 0
.gitignore

@@ -139,3 +139,4 @@ base
 
 # overlays
 custom_overlays
+overlay_db

+ 1 - 0
docker-compose.yml

@@ -47,5 +47,6 @@ services:
     restart: always
     volumes:
       - ./custom_overlays:/srv:rw
+      - ./overlay_db:/app/db   # <-- We mount a whole folder here now
     ports:
       - "0.0.0.0:11081:80"

+ 346 - 72
overlay_manager/main.py

@@ -1,8 +1,10 @@
 import os
+import sqlite3
 import shutil
-import logging
 import tempfile
-from fastapi import FastAPI, Request, File, UploadFile, Form
+import logging
+from urllib.parse import quote
+from fastapi import FastAPI, Request, File, UploadFile, Form, HTTPException
 from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
 from fastapi.templating import Jinja2Templates
 from fastapi.staticfiles import StaticFiles
@@ -11,96 +13,368 @@ from fastapi.staticfiles import StaticFiles
 logging.basicConfig(level=logging.INFO)
 logger = logging.getLogger("overlay-manager")
 
-# root_path ensures FastAPI knows it lives at /patch-manager/
 app = FastAPI(root_path="/patch-manager")
 
 os.makedirs("static", exist_ok=True)
 app.mount("/static", StaticFiles(directory="static"), name="static")
 templates = Jinja2Templates(directory="templates")
 
-OVERLAY_DIR = "/srv"
+OVERLAY_DIR = os.path.abspath("/srv")
+
+# Use the new mounted directory for the database
+DB_DIR = "/app/db"
+os.makedirs(DB_DIR, exist_ok=True)
+DB_PATH = os.path.join(DB_DIR, "overlays.db")
+
+def init_db():
+    """Initializes the SQLite database and schema."""
+    with sqlite3.connect(DB_PATH) as conn:
+        conn.execute("""
+            CREATE TABLE IF NOT EXISTS profiles (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                name TEXT UNIQUE NOT NULL,
+                is_active BOOLEAN NOT NULL DEFAULT 0
+            )
+        """)
+        conn.execute("""
+            CREATE TABLE IF NOT EXISTS files (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                profile_id INTEGER NOT NULL,
+                filepath TEXT NOT NULL,
+                content BLOB NOT NULL,
+                FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE,
+                UNIQUE(profile_id, filepath)
+            )
+        """)
+        
+        # Auto-migrate: Add description and is_readonly columns if they don't exist
+        cursor = conn.cursor()
+        cursor.execute("PRAGMA table_info(profiles)")
+        columns = [info[1] for info in cursor.fetchall()]
+        if "description" not in columns:
+            cursor.execute("ALTER TABLE profiles ADD COLUMN description TEXT DEFAULT ''")
+        if "is_readonly" not in columns:
+            cursor.execute("ALTER TABLE profiles ADD COLUMN is_readonly BOOLEAN NOT NULL DEFAULT 0")
+        
+        # Create default profiles if the DB is empty
+        cursor.execute("SELECT count(*) FROM profiles")
+        if cursor.fetchone()[0] == 0:
+            cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES ('Vanilla (No Overlays)', 'Baseline ArduPilot build without any modifications. Read-only.', 1, 1)")
+            cursor.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES ('Custom Build Alpha', 'Experimental tuning parameters.', 0, 0)")
+        else:
+            # Lock the existing Vanilla profile for users upgrading
+            cursor.execute("UPDATE profiles SET is_readonly = 1 WHERE name = 'Vanilla (No Overlays)'")
+            
+        conn.commit()
+
+init_db()
+
+def get_db_connection():
+    conn = sqlite3.connect(DB_PATH)
+    conn.row_factory = sqlite3.Row
+    return conn
 
 @app.get("/", response_class=HTMLResponse)
-async def index(request: Request):
+async def index(request: Request, success_msg: str = None, error_msg: str = None):
+    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()
+    
     files = []
-    dirs = set([""])
-    if os.path.exists(OVERLAY_DIR):
-        for root, dirnames, filenames in os.walk(OVERLAY_DIR):
-            rel_root = os.path.relpath(root, OVERLAY_DIR)
-            if rel_root != ".":
-                dirs.add(rel_root)
-            for filename in filenames:
-                rel_path = os.path.relpath(os.path.join(root, filename), OVERLAY_DIR)
-                files.append(rel_path)
+    dirs = set()
+    
+    if active_profile:
+        files = conn.execute("SELECT * FROM files WHERE profile_id = ? ORDER BY filepath", (active_profile['id'],)).fetchall()
+        for f in files:
+            dirname = os.path.dirname(f['filepath'])
+            if dirname:
+                dirs.add(dirname)
+                
+    conn.close()
                 
     return templates.TemplateResponse("index.html", {
         "request": request, 
-        "files": sorted(files),
-        "dirs": sorted(list(dirs))
+        "profiles": profiles,
+        "active_profile": active_profile,
+        "files": files,
+        "dirs": sorted(list(dirs)),
+        "success_msg": success_msg,
+        "error_msg": error_msg
     })
 
-@app.get("/download-all")
-async def download_all():
-    """Zips the entire /srv directory and serves it as a backup."""
-    with tempfile.TemporaryDirectory() as tmpdir:
-        zip_base = os.path.join(tmpdir, "backup")
-        shutil.make_archive(zip_base, 'zip', OVERLAY_DIR)
-        return FileResponse(
-            path=f"{zip_base}.zip", 
-            filename="ardupilot_overlays_backup.zip",
-            media_type='application/zip'
-        )
+@app.post("/profile/create")
+async def create_profile(name: str = Form(...)):
+    try:
+        with get_db_connection() as conn:
+            conn.execute("INSERT INTO profiles (name, description, is_active, is_readonly) VALUES (?, '', 0, 0)", (name.strip(),))
+            conn.commit()
+        msg = quote(f"Profile '{name}' created.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except sqlite3.IntegrityError:
+        msg = quote("A profile with that name already exists.")
+        return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Profile creation failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to create profile.", status_code=303)
 
-@app.post("/upload")
-async def upload_file(file: UploadFile = File(...), target_path: str = Form("")):
-    save_dir = os.path.join(OVERLAY_DIR, target_path.strip("/"))
-    os.makedirs(save_dir, exist_ok=True)
-    file_location = os.path.join(save_dir, file.filename)
-    with open(file_location, "wb+") as file_object:
-        shutil.copyfileobj(file.file, file_object)
-    return RedirectResponse(url="./", status_code=303)
-
-@app.post("/create_folder")
-async def create_folder(folder_path: str = Form(...)):
-    save_dir = os.path.join(OVERLAY_DIR, folder_path.strip("/"))
-    os.makedirs(save_dir, exist_ok=True)
-    return RedirectResponse(url="./", status_code=303)
-
-@app.post("/delete")
-async def delete_file(filepath: str = Form(...)):
-    target = os.path.join(OVERLAY_DIR, filepath.lstrip("/"))
-    if os.path.exists(target):
-        if os.path.isdir(target):
-            shutil.rmtree(target)
-        else:
-            os.remove(target)
-    return RedirectResponse(url="./", status_code=303)
+@app.post("/profile/rename")
+async def rename_profile(profile_id: int = Form(...), new_name: str = Form(...)):
+    try:
+        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)
+                
+            conn.execute("UPDATE profiles SET name = ? WHERE id = ?", (new_name.strip(), profile_id))
+            conn.commit()
+        msg = quote(f"Profile renamed to '{new_name}'.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except sqlite3.IntegrityError:
+        msg = quote("A profile with that name already exists.")
+        return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Profile rename failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to rename profile.", status_code=303)
 
-@app.get("/edit", response_class=HTMLResponse)
-async def edit_file(request: Request, filepath: str):
-    clean_path = filepath.lstrip("/")
-    target = os.path.join(OVERLAY_DIR, clean_path)
-    logger.info(f"Attempting to edit: {target}")
+@app.post("/profile/annotate")
+async def annotate_profile(profile_id: int = Form(...), description: str = Form("")):
+    try:
+        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)
 
-    if not os.path.exists(target):
-        return HTMLResponse(content=f"<h1>404 Not Found</h1><p>File {target} missing.</p><a href='./'>Back</a>", status_code=404)
+            conn.execute("UPDATE profiles SET description = ? WHERE id = ?", (description, profile_id))
+            conn.commit()
+        msg = quote("Notes saved successfully.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Annotation failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to save notes.", status_code=303)
 
+@app.post("/profile/duplicate")
+async def duplicate_profile(profile_id: int = Form(...)):
     try:
-        with open(target, "r", encoding="utf-8", errors="ignore") as f:
-            content = f.read()
+        with get_db_connection() as conn:
+            cursor = conn.cursor()
+            
+            src = cursor.execute("SELECT name, description FROM profiles WHERE id = ?", (profile_id,)).fetchone()
+            if not src:
+                raise Exception("Source profile not found.")
+            
+            base_name = src['name']
+            desc = src['description'] if src['description'] else ""
+            
+            new_name = f"{base_name} (Copy)"
+            counter = 1
+            while True:
+                exists = cursor.execute("SELECT id FROM profiles WHERE name = ?", (new_name,)).fetchone()
+                if not exists:
+                    break
+                counter += 1
+                new_name = f"{base_name} (Copy {counter})"
+            
+            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))
+            new_profile_id = cursor.lastrowid
+            
+            files = cursor.execute("SELECT filepath, content FROM files WHERE profile_id = ?", (profile_id,)).fetchall()
+            for f in files:
+                cursor.execute("INSERT INTO files (profile_id, filepath, content) VALUES (?, ?, ?)", 
+                               (new_profile_id, f['filepath'], f['content']))
+            conn.commit()
+            
+        msg = quote(f"Seamlessly cloned and activated '{new_name}'.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
     except Exception as e:
-        return HTMLResponse(content=f"<h1>Error</h1><p>{str(e)}</p>", status_code=500)
+        logger.error(f"Profile duplication failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to duplicate profile.", status_code=303)
 
-    return templates.TemplateResponse("edit.html", {
-        "request": request, 
-        "filepath": filepath, 
-        "content": content
-    })
+@app.post("/profile/activate")
+async def activate_profile(profile_id: int = Form(...)):
+    try:
+        with get_db_connection() as conn:
+            conn.execute("UPDATE profiles SET is_active = 0")
+            conn.execute("UPDATE profiles SET is_active = 1 WHERE id = ?", (profile_id,))
+            conn.commit()
+        return RedirectResponse(url=f"/patch-manager/", status_code=303)
+    except Exception as e:
+        logger.error(f"Profile activation failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to activate profile.", status_code=303)
+
+@app.post("/profile/delete")
+async def delete_profile(profile_id: int = Form(...)):
+    try:
+        with get_db_connection() as conn:
+            prof = conn.execute("SELECT is_readonly, is_active 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)
+                
+            conn.execute("DELETE FROM profiles WHERE id = ?", (profile_id,))
+            conn.commit()
+        msg = quote("Profile deleted successfully.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Profile deletion failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to delete profile.", status_code=303)
+
+@app.post("/deploy")
+async def deploy_active_profile():
+    try:
+        with get_db_connection() as conn:
+            active = conn.execute("SELECT id, name FROM profiles WHERE is_active = 1").fetchone()
+            if not active:
+                raise Exception("No active profile selected.")
+                
+            files = conn.execute("SELECT filepath, content FROM files WHERE profile_id = ?", (active['id'],)).fetchall()
+            
+        if os.path.exists(OVERLAY_DIR):
+            for item in os.listdir(OVERLAY_DIR):
+                item_path = os.path.join(OVERLAY_DIR, item)
+                if os.path.isfile(item_path):
+                    os.remove(item_path)
+                elif os.path.isdir(item_path):
+                    shutil.rmtree(item_path)
+        os.makedirs(OVERLAY_DIR, exist_ok=True)
+        
+        count = 0
+        for f in files:
+            target = os.path.abspath(os.path.join(OVERLAY_DIR, f['filepath'].lstrip("/")))
+            if target.startswith(OVERLAY_DIR):
+                os.makedirs(os.path.dirname(target), exist_ok=True)
+                with open(target, "wb") as out_file:
+                    out_file.write(f['content'])
+                count += 1
+                
+        return RedirectResponse(url="/", status_code=303)
+    except Exception as e:
+        logger.error(f"Deployment failed: {e}")
+        msg = quote("Failed to deploy files to staging directory.")
+        return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+
+@app.post("/file/upload")
+async def upload_file(
+    file: UploadFile = File(...), 
+    existing_path: str = Form(""), 
+    new_path: str = Form(""), 
+    profile_id: int = Form(...)
+):
+    try:
+        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)
+
+            target_path = new_path.strip() if new_path.strip() else existing_path.strip()
+            clean_path = target_path.strip("/")
+            
+            safe_filename = os.path.basename(file.filename)
+            full_filepath = os.path.join(clean_path, safe_filename) if clean_path else safe_filename
+            
+            content = await file.read()
+
+            conn.execute("""
+                INSERT INTO files (profile_id, filepath, content) 
+                VALUES (?, ?, ?)
+                ON CONFLICT(profile_id, filepath) 
+                DO UPDATE SET content=excluded.content
+            """, (profile_id, full_filepath, content))
+            conn.commit()
+            
+        msg = quote(f"Successfully saved {safe_filename} to profile.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Upload failed: {e}")
+        msg = quote("Failed to upload file to database.")
+        return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+
+@app.post("/file/delete")
+async def delete_file(file_id: int = Form(...)):
+    try:
+        with get_db_connection() as conn:
+            conn.execute("DELETE FROM files WHERE id = ?", (file_id,))
+            conn.commit()
+        return RedirectResponse(url=f"/patch-manager/", status_code=303)
+    except Exception as e:
+        logger.error(f"File deletion failed: {e}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg=Failed to delete file.", status_code=303)
+
+@app.get("/edit", response_class=HTMLResponse)
+async def edit_file(request: Request, file_id: int, error_msg: str = None):
+    try:
+        with get_db_connection() as conn:
+            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()
+            
+        if not file_record:
+            msg = quote("File not found in database.")
+            return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+
+        try:
+            content_text = file_record['content'].decode('utf-8')
+        except UnicodeDecodeError:
+            msg = quote(f"Safety Lock: {os.path.basename(file_record['filepath'])} appears to be a binary file and cannot be opened in the text editor.")
+            return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
+
+        return templates.TemplateResponse("edit.html", {
+            "request": request, 
+            "file_id": file_id,
+            "filepath": file_record['filepath'], 
+            "content": content_text,
+            "active_profile": active_profile,
+            "profiles": profiles,
+            "error_msg": error_msg
+        })
+    except Exception as e:
+        logger.error(f"Edit GET failed: {e}")
+        msg = quote(f"System Error loading file: {str(e)}")
+        return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)
 
 @app.post("/edit")
-async def save_file(filepath: str = Form(...), content: str = Form(...)):
-    target = os.path.join(OVERLAY_DIR, filepath.lstrip("/"))
-    if os.path.exists(target):
-        with open(target, "w", encoding="utf-8") as f:
-            f.write(content)
-    return RedirectResponse(url="./", status_code=303)
+async def save_file(file_id: int = Form(...), content: str = Form(...)):
+    try:
+        content_bytes = content.encode('utf-8')
+        with get_db_connection() as conn:
+            conn.execute("UPDATE files SET content = ? WHERE id = ?", (content_bytes, file_id))
+            conn.commit()
+            
+        msg = quote("Successfully saved changes.")
+        return RedirectResponse(url=f"/patch-manager/?success_msg={msg}", status_code=303)
+    except Exception as e:
+        logger.error(f"Edit POST failed: {e}")
+        msg = quote("Failed to save file.")
+        return RedirectResponse(url=f"/patch-manager/edit?file_id={file_id}&error_msg={msg}", status_code=303)
+
+@app.get("/download")
+async def download_single_file(file_id: int):
+    try:
+        with get_db_connection() as conn:
+            file_record = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
+            
+        if not file_record:
+            raise HTTPException(status_code=404, detail="File not found")
+            
+        with tempfile.NamedTemporaryFile(delete=False) as tmp:
+            tmp.write(file_record['content'])
+            tmp_path = tmp.name
+            
+        return FileResponse(
+            path=tmp_path, 
+            filename=os.path.basename(file_record['filepath']),
+            media_type='application/octet-stream'
+        )
+    except Exception as e:
+        logger.error(f"Download single file failed: {e}")
+        msg = quote("Failed to download file.")
+        return RedirectResponse(url=f"/patch-manager/?error_msg={msg}", status_code=303)

+ 132 - 20
overlay_manager/templates/edit.html

@@ -2,35 +2,147 @@
 <html lang="en">
 <head>
     <meta charset="UTF-8">
-    <title>Edit - {{ filepath }}</title>
+    <title>ArduPilot Overlay Manager - Edit</title>
     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
+    
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.css">
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/theme/monokai.min.css">
+    
+    <style>
+        html, body { height: 100%; }
+        body {
+            display: flex;
+            flex-direction: column;
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+            background-color: #f4f6f9;
+        }
+        .content-wrapper { flex: 1 0 auto; }
+        .navbar-brand { font-size: 25px; }
+        .card { border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); border: none;}
+        footer { flex-shrink: 0; }
+        
+        .CodeMirror {
+            height: 600px;
+            font-size: 14px;
+            font-family: 'Courier New', Courier, monospace;
+            border-radius: 0;
+        }
+        
+        .profile-list-item.active {
+            background-color: #0d6efd;
+            border-color: #0d6efd;
+            color: white;
+        }
+        .profile-list-item.active .text-muted {
+            color: rgba(255,255,255,0.9) !important;
+        }
+    </style>
 </head>
-<body class="bg-light">
-    <nav class="navbar navbar-dark shadow-sm py-2 mb-4" style="background-color: #0B61A4;">
-        <div class="container">
-            <span class="navbar-brand mb-0 h4"><i class="bi bi-pencil-square me-2"></i>Overlay Editor</span>
-            <a href="./" class="btn btn-outline-light btn-sm"><i class="bi bi-arrow-left me-1"></i>Back to Manager</a>
-        </div>
-    </nav>
+<body>
+    <div class="content-wrapper">
+        <nav class="navbar navbar-dark bg-dark shadow-sm py-2">
+            <div class="container-fluid px-4">
+                <div class="d-flex align-items-center">
+                    <a class="navbar-brand d-flex align-items-center" href="/">
+                        <img src="/static/images/ardupilot_logo.png" alt="ArduPilot" height="24" class="d-inline-block align-text-top me-2">
+                        <span class="text-white">Overlay Manager</span>
+                    </a>
+                </div>
+                <div class="ms-auto d-flex align-items-center">
+                    <a href="javascript:void(0);" onclick="window.location.href = window.location.protocol + '//' + window.location.hostname;" class="btn btn-outline-light">
+                        <i class="bi bi-arrow-left me-2"></i>Back to Main App
+                    </a>
+                </div>
+            </div>
+        </nav>
 
-    <div class="container">
-        <div class="card shadow border-0 rounded-3 mb-5">
-            <div class="card-header bg-white pt-3 pb-2 d-flex justify-content-between align-items-center">
-                <span class="font-monospace fw-bold text-primary">{{ filepath }}</span>
+        <div class="container-fluid px-4 mt-4">
+            {% if error_msg %}
+            <div class="alert alert-danger alert-dismissible fade show shadow-sm" role="alert">
+                <i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error_msg }}
+                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
             </div>
-            <div class="card-body p-0">
-                <form action="edit" method="post">
-                    <input type="hidden" name="filepath" value="{{ filepath }}">
-                    <textarea name="content" class="form-control border-0 font-monospace text-bg-dark rounded-0 p-3" rows="25" style="resize: vertical; background-color: #1e1e1e; color: #d4d4d4;">{{ content }}</textarea>
+            {% endif %}
+
+            <div class="row g-4">
+                <div class="col-md-3">
+                    <div class="card mb-4">
+                        <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>
+                        </div>
+                        <div class="list-group list-group-flush">
+                            {% for p in profiles %}
+                            <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 %}">
+                                    {{ p.name }}
+                                    {% if p.id == active_profile.id %}<i class="bi bi-check-circle-fill ms-2"></i>{% endif %}
+                                </div>
+                            </div>
+                            {% endfor %}
+                        </div>
+                        <div class="card-footer bg-light p-3 text-center">
+                            <small class="text-muted"><i class="bi bi-info-circle me-1"></i> Return to the dashboard to switch profiles.</small>
+                        </div>
+                    </div>
+                </div>
 
-                    <div class="p-3 bg-light text-end rounded-bottom-3">
-                        <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>Commit Changes</button>
+                <div class="col-md-9">
+                    <div class="card h-100 mb-5 border-0 shadow-sm">
+                        <div class="card-header bg-white py-3 d-flex justify-content-between align-items-center border-bottom-0">
+                            <h5 class="card-title mb-0 fw-bold text-dark">
+                                <i class="bi bi-pencil-square me-2 text-primary"></i>Editing: <span class="text-primary font-monospace">{{ filepath }}</span>
+                            </h5>
+                            <a href="./" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i> Back to Loadout</a>
+                        </div>
+                        
+                        <div class="card-body p-0">
+                            <form action="edit" method="post" id="editForm">
+                                <input type="hidden" name="file_id" value="{{ file_id }}">
+                                
+                                <textarea id="code-editor" name="content">{{ content }}</textarea>
+
+                                <div class="p-3 bg-light text-end rounded-bottom border-top">
+                                    <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>
+                                </div>
+                            </form>
+                        </div>
                     </div>
-                </form>
+                </div>
             </div>
         </div>
     </div>
+
+    <footer class="py-3 bg-dark text-white-50 mt-auto">
+        <div class="container-fluid px-4 d-flex justify-content-between">
+            <div>
+                <span class="mx-2">Credits: <a href="https://github.com/ArduPilot/CustomBuild/graphs/contributors" style="text-decoration: underline; color: white;">See Contributors</a></span>|
+                <span class="mx-2">Source: <a href="https://git.equalmass.com/Equalmass/ArdupilotCustomFirmwareBuilder" style="text-decoration: underline; color: white;">Ardupilot/CustomBuild</a></span>
+            </div>
+            <span>ArduPilot Overlay Manager</span>
+        </div>
+    </footer>
+    
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+    
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/mode/clike/clike.min.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/mode/python/python.min.js"></script>
+    
+    <script>
+        var editor = CodeMirror.fromTextArea(document.getElementById("code-editor"), {
+            lineNumbers: true,
+            mode: "text/x-c++src", 
+            theme: "monokai",
+            matchBrackets: true,
+            indentUnit: 4,
+            indentWithTabs: false
+        });
+        
+        document.getElementById('editForm').addEventListener('submit', function() {
+            editor.save();
+        });
+    </script>
 </body>
 </html>

+ 229 - 80
overlay_manager/templates/index.html

@@ -6,27 +6,30 @@
     <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
     <style>
-        /* Ensures the body takes up at least the full height of the viewport */
-        html, body {
-            height: 100%;
-        }
+        html, body { height: 100%; }
         body {
             display: flex;
             flex-direction: column;
             font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+            background-color: #f4f6f9;
         }
-        .content-wrapper {
-            flex: 1 0 auto; /* Pushes the footer down */
-        }
+        .content-wrapper { flex: 1 0 auto; }
         .navbar-brand { font-size: 25px; }
-        .card { border-radius: 8px; }
+        .card { border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); border: none;}
         .table-light { background-color: #f8f9fa; }
-        footer {
-            flex-shrink: 0; /* Prevents footer from shrinking */
+        footer { flex-shrink: 0; }
+        .profile-list-item.active {
+            background-color: #0d6efd;
+            border-color: #0d6efd;
+            color: white;
         }
+        .profile-list-item.active .text-muted, .profile-list-item.active .text-secondary {
+            color: rgba(255,255,255,0.8) !important;
+        }
+        .dropdown-item i { width: 20px; text-align: center; }
     </style>
 </head>
-<body class="bg-light">
+<body>
     <div class="content-wrapper">
         <nav class="navbar navbar-dark bg-dark shadow-sm py-2">
             <div class="container-fluid px-4">
@@ -36,7 +39,7 @@
                         <span class="text-white">Overlay Manager</span>
                     </a>
                 </div>
-                <div class="ms-auto">
+                <div class="ms-auto d-flex align-items-center">
                     <a href="javascript:void(0);" onclick="window.location.href = window.location.protocol + '//' + window.location.hostname;" class="btn btn-outline-light">
                         <i class="bi bi-arrow-left me-2"></i>Back to Main App
                     </a>
@@ -45,98 +48,235 @@
         </nav>
 
         <div class="container-fluid px-4 mt-4">
-            <div class="row mb-4 g-4">
-                <div class="col-md-7">
-                    <div class="card shadow-sm border-0 h-100">
-                        <div class="card-header bg-white py-3">
-                            <h5 class="card-title mb-0 fw-bold text-dark">
-                                <i class="bi bi-cloud-arrow-up-fill me-2 text-info"></i>Upload to Folder
-                            </h5>
+            {% if success_msg %}
+            <div class="alert alert-success alert-dismissible fade show shadow-sm" role="alert">
+                <i class="bi bi-check-circle-fill me-2"></i>{{ success_msg }}
+                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+            </div>
+            {% endif %}
+            
+            {% if error_msg %}
+            <div class="alert alert-danger alert-dismissible fade show shadow-sm" role="alert">
+                <i class="bi bi-exclamation-triangle-fill me-2"></i>{{ error_msg }}
+                <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+            </div>
+            {% endif %}
+
+            <div class="row g-4">
+                <div class="col-md-3">
+                    <div class="card mb-4">
+                        <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>
                         </div>
-                        <div class="card-body p-4">
-                            <form action="/patch-manager/upload" method="post" enctype="multipart/form-data" class="d-flex flex-column gap-3">
-                                <label class="form-label small fw-bold text-muted mb-0">Select File</label>
-                                <input class="form-control" type="file" name="file" required>
+                        <div class="list-group list-group-flush">
+                            {% for p in profiles %}
+                            <div class="list-group-item d-flex justify-content-between align-items-center profile-list-item {% if p.is_active %}active{% endif %}">
+                                <form action="/patch-manager/profile/activate" method="post" class="m-0 p-0 flex-grow-1 cursor-pointer" style="cursor: pointer;">
+                                    <input type="hidden" name="profile_id" value="{{ p.id }}">
+                                    <div onclick="this.parentNode.submit();" class="w-100 fw-bold">
+                                        {{ p.name }}
+                                        {% if p.is_readonly %}<i class="bi bi-lock-fill ms-1" title="Read-Only"></i>{% endif %}
+                                        {% if p.is_active %}<i class="bi bi-check-circle-fill ms-2"></i>{% endif %}
+                                    </div>
+                                </form>
                                 
-                                <label class="form-label small fw-bold text-muted mb-0">Target Directory</label>
-                                <div class="input-group">
-                                    <span class="input-group-text bg-light"><i class="bi bi-folder2-open"></i></span>
-                                    <select class="form-select" name="target_path">
-                                        {% for d in dirs %}
-                                            <option value="{{ d }}">{% if d == "" %}/ (Root Directory){% else %}{{ d }}{% endif %}</option>
-                                        {% endfor %}
-                                    </select>
+                                <div class="dropdown">
+                                    <button class="btn btn-sm btn-link p-0 text-decoration-none {% if p.is_active %}text-white{% else %}text-secondary{% endif %}" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Profile Options">
+                                        <i class="bi bi-three-dots-vertical fs-5"></i>
+                                    </button>
+                                    <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>
+                                        {% endif %}
+                                        <li>
+                                            <form action="/patch-manager/profile/duplicate" method="post" class="m-0 p-0">
+                                                <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>
+                                            </form>
+                                        </li>
+                                        {% if not p.is_active and not p.is_readonly %}
+                                        <li><hr class="dropdown-divider"></li>
+                                        <li>
+                                            <form action="/patch-manager/profile/delete" method="post" class="m-0 p-0" onsubmit="return confirm('Delete this profile completely?');">
+                                                <input type="hidden" name="profile_id" value="{{ p.id }}">
+                                                <button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Delete Profile</button>
+                                            </form>
+                                        </li>
+                                        {% endif %}
+                                    </ul>
                                 </div>
+                                
+                            </div>
+                            {% endfor %}
+                        </div>
+                        <div class="card-footer bg-light p-3">
+                            <form action="/patch-manager/profile/create" method="post" class="input-group">
+                                <input type="text" class="form-control form-control-sm" name="name" placeholder="New profile name..." required>
+                                <button class="btn btn-primary btn-sm" type="submit"><i class="bi bi-plus"></i> Add</button>
+                            </form>
+                        </div>
+                    </div>
 
-                                <button type="submit" class="btn btn-primary fw-bold mt-2">
-                                    <i class="bi bi-upload me-2"></i>Upload File
+                    <div class="card bg-dark text-white shadow">
+                        <div class="card-body text-center p-4">
+                            <i class="bi bi-rocket-takeoff-fill display-4 text-warning mb-3 d-block"></i>
+                            <h5 class="fw-bold">Ready to Compile?</h5>
+                            <p class="small text-white-50">Pushing to the builder will wipe the staging directory and write the currently active profile.</p>
+                            <form action="/patch-manager/deploy" method="post" onsubmit="return confirm('This will deploy {{ active_profile.name }} to the builder. Are you sure?');">
+                                <button type="submit" class="btn btn-warning fw-bold w-100 py-2 fs-5">
+                                    DEPLOY TO BUILDER
                                 </button>
                             </form>
                         </div>
                     </div>
                 </div>
 
-                <div class="col-md-5">
-                    <div class="card shadow-sm border-0 h-100">
+                <div class="col-md-9">
+
+                    <div class="card shadow-sm border-0 mb-4">
                         <div class="card-header bg-white py-3">
+                            <h6 class="card-title mb-0 fw-bold text-dark"><i class="bi bi-card-text text-warning me-2"></i>Profile Notes & Annotations</h6>
+                        </div>
+                        <div class="card-body p-3 bg-light">
+                            <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 }}">
+                                <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 %}
+                                <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>
+                                </div>
+                                {% endif %}
+                            </form>
+                        </div>
+                    </div>
+                    
+                    <div class="card shadow-sm border-0 mb-4">
+                        <div class="card-header bg-white py-3">
+                            <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>
+                        
+                        {% if active_profile.is_readonly %}
+                        <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>
+                            <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>
+                        </div>
+                        {% else %}
+                        <div class="card-body p-3 bg-light">
+                            <form action="/patch-manager/file/upload" method="post" enctype="multipart/form-data" class="row g-3 align-items-end">
+                                <input type="hidden" name="profile_id" value="{{ active_profile.id }}">
+                                
+                                <div class="col-md-4">
+                                    <label class="form-label small fw-bold text-muted mb-1">1. Select File</label>
+                                    <input class="form-control form-control-sm" type="file" name="file" required>
+                                </div>
+                                
+                                <div class="col-md-3">
+                                    <label class="form-label small fw-bold text-muted mb-1">2. Existing Folder</label>
+                                    <select class="form-select form-select-sm" name="existing_path" onchange="document.getElementById('new_path').value=''">
+                                        <option value="">/ (Root Directory)</option>
+                                        {% for d in dirs %}
+                                            <option value="{{ d }}">{{ d }}</option>
+                                        {% endfor %}
+                                    </select>
+                                </div>
+                                
+                                <div class="col-md-3">
+                                    <label class="form-label small fw-bold text-muted mb-1">...Or Create New Folder</label>
+                                    <input type="text" class="form-control form-control-sm font-monospace" name="new_path" id="new_path" placeholder="e.g. libraries/AP_HAL" oninput="document.getElementsByName('existing_path')[0].value=''">
+                                </div>
+                                
+                                <div class="col-md-2">
+                                    <button type="submit" class="btn btn-success btn-sm fw-bold w-100"><i class="bi bi-upload me-1"></i> Upload</button>
+                                </div>
+                            </form>
+                        </div>
+                        {% endif %}
+                    </div>
+
+                    <div class="card mb-5 border-0 shadow-sm">
+                        <div class="card-header bg-white py-3 d-flex justify-content-between align-items-center border-bottom-0">
                             <h5 class="card-title mb-0 fw-bold text-dark">
-                                <i class="bi bi-folder-plus me-2 text-success"></i>Create New Folder
+                                <i class="bi bi-folder2-open me-2 text-info"></i>Files for: <span class="text-primary">{{ active_profile.name }}</span>
                             </h5>
                         </div>
-                        <div class="card-body p-4">
-                            <form action="/patch-manager/create_folder" method="post" class="d-flex flex-column gap-3">
-                                <label class="form-label small fw-bold text-muted mb-0">Folder Path</label>
-                                <input type="text" class="form-control" name="folder_path" placeholder="e.g., libraries/AP_HAL" required>
-                                <button type="submit" class="btn btn-success fw-bold mt-2">
-                                    <i class="bi bi-plus-lg me-2"></i>Create Folder
-                                </button>
-                            </form>
+                        <div class="card-body p-0">
+                            <div class="table-responsive">
+                                <table class="table table-hover align-middle mb-0">
+                                    <thead class="table-light">
+                                        <tr>
+                                            <th class="ps-4 py-3">Target Path</th>
+                                            <th class="text-end pe-4 py-3">Actions</th>
+                                        </tr>
+                                    </thead>
+                                    <tbody>
+                                        {% for file in files %}
+                                        <tr>
+                                            <td class="ps-4 font-monospace text-dark" style="font-size: 0.95rem;">
+                                                <i class="bi bi-file-code me-2 text-secondary"></i>{{ file.filepath }}
+                                            </td>
+                                            <td class="text-end pe-4">
+                                                <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>
+                                                </a>
+                                                <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
+                                                </a>
+                                                <form action="/patch-manager/file/delete" method="post" class="d-inline" onsubmit="return confirm('Remove this file from the profile?');">
+                                                    <input type="hidden" name="file_id" value="{{ file.id }}">
+                                                    <button type="submit" class="btn btn-sm btn-outline-danger">
+                                                        <i class="bi bi-trash"></i>
+                                                    </button>
+                                                </form>
+                                            </td>
+                                        </tr>
+                                        {% else %}
+                                        <tr>
+                                            <td colspan="2" class="text-center py-5 text-muted">
+                                                <i class="bi bi-inbox fs-1 d-block mb-3 text-black-50"></i>
+                                                <h5>This profile is empty.</h5>
+                                                <p>Deploying this profile will result in a clean, vanilla build.<br>Upload a file above to start creating overlays.</p>
+                                            </td>
+                                        </tr>
+                                        {% endfor %}
+                                    </tbody>
+                                </table>
+                            </div>
                         </div>
                     </div>
                 </div>
             </div>
+        </div>
+    </div>
 
-            <div class="card shadow-sm border-0 mb-5">
-                <div class="card-header bg-white py-3">
-                    <h5 class="card-title mb-0 fw-bold text-dark">
-                        <i class="bi bi-files me-2 text-primary"></i>Managed Files
-                    </h5>
-                </div>
-                <div class="card-body p-0">
-                    <div class="table-responsive">
-                        <table class="table table-hover align-middle mb-0">
-                            <thead class="table-light">
-                                <tr>
-                                    <th class="ps-4 py-3">Source Path</th>
-                                    <th class="text-end pe-4 py-3">Actions</th>
-                                </tr>
-                            </thead>
-                            <tbody>
-                                {% for file in files %}
-                                <tr>
-                                    <td class="ps-4 font-monospace text-primary" style="font-size: 0.9rem;">{{ file }}</td>
-                                    <td class="text-end pe-4">
-                                        <a href="/patch-manager/edit?filepath={{ file }}" class="btn btn-sm btn-outline-primary me-2">
-                                            <i class="bi bi-pencil me-1"></i> Edit
-                                        </a>
-                                        <form action="/patch-manager/delete" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this file?');">
-                                            <input type="hidden" name="filepath" value="{{ file }}">
-                                            <button type="submit" class="btn btn-sm btn-outline-danger">
-                                                <i class="bi bi-trash me-1"></i> Delete
-                                            </button>
-                                        </form>
-                                    </td>
-                                </tr>
-                                {% endfor %}
-                            </tbody>
-                        </table>
+    <div class="modal fade" id="renameModal" tabindex="-1" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <form action="/patch-manager/profile/rename" method="post">
+                    <div class="modal-header bg-light">
+                        <h5 class="modal-title fw-bold text-dark"><i class="bi bi-pencil-square me-2"></i>Rename Profile</h5>
+                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                     </div>
-                </div>
+                    <div class="modal-body p-4">
+                        <input type="hidden" name="profile_id" id="rename-profile-id">
+                        <div class="mb-3">
+                            <label class="form-label text-muted small fw-bold">Profile Name</label>
+                            <input type="text" class="form-control" name="new_name" id="rename-new-name" 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-primary fw-bold"><i class="bi bi-check-lg me-1"></i>Save Name</button>
+                    </div>
+                </form>
             </div>
         </div>
     </div>
 
-    <footer class="py-3 bg-dark text-white-50">
+    <footer class="py-3 bg-dark text-white-50 mt-auto">
         <div class="container-fluid px-4 d-flex justify-content-between">
             <div>
                 <span class="mx-2">Credits: 
@@ -149,5 +289,14 @@
             <span>ArduPilot Overlay Manager</span>
         </div>
     </footer>
+    
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+    <script>
+        function openRenameModal(profileId, profileName) {
+            document.getElementById('rename-profile-id').value = profileId;
+            document.getElementById('rename-new-name').value = profileName;
+            new bootstrap.Modal(document.getElementById('renameModal')).show();
+        }
+    </script>
 </body>
 </html>