From cd819002b43507427cd35403aefe359756b05cc9 Mon Sep 17 00:00:00 2001 From: D Stephenson Date: Thu, 23 Apr 2026 20:37:17 +0000 Subject: [PATCH] Add reliability enhancements, reachability pre-check, and AT presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High-impact reliability: - SQLite job persistence (rv50x.db) — job history and AT sessions survive restarts - Extract _login_and_open_modal() — eliminates ~40 lines of duplicated Playwright login logic - Separate NocoDB view IDs per group via NOCODB_VIEW_ID_ELECTRIC / NOCODB_VIEW_ID_GW env vars - Excel cache TTL (1h) + size cap (20 files) with eviction helpers - In-memory job store pruning (MAX_JOBS_MEMORY, default 200) Functionality: - TCP reachability pre-check before launching Playwright — fails fast on unreachable devices - AT command presets — save/load/delete named command sequences, stored in at_presets.json Ops: - Bind-mount rv50x.db and at_presets.json in docker-compose so data survives rebuilds - Add NOCODB_VIEW_ID_ELECTRIC, NOCODB_VIEW_ID_GW, REACH_TIMEOUT env vars to compose - Ignore runtime files (rv50x.db, at_presets.json, template dirs) in .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 + app.py | 404 +++++++++++++++++++++++++++++++++++---------- docker-compose.yml | 6 +- index.html | 76 ++++++++- 4 files changed, 398 insertions(+), 93 deletions(-) diff --git a/.gitignore b/.gitignore index 4b79735..818a405 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ certs/ __pycache__/ *.pyc +rv50x.db +at_presets.json +template_downloads/ +template_uploads/ +template_backups/ diff --git a/app.py b/app.py index 0346642..9d39f5d 100644 --- a/app.py +++ b/app.py @@ -8,15 +8,22 @@ Run via Docker: docker-compose up -d Environment variables (set in .env or docker-compose.yml): - NOCODB_URL NocoDB base URL e.g. http://192.168.16.130:8080 - NOCODB_TOKEN NocoDB API token - NOCODB_BASE_ID Base ID from browser URL - NOCODB_TABLE_ID Table ID from browser URL - NOCODB_VIEW_ID View ID from browser URL (the "All" view) - PAGE_TIMEOUT ms to wait for page elements (default 90000) - DOWNLOAD_TIMEOUT ms to wait for template gen (default 120000) - UPLOAD_TIMEOUT ms to wait for upload done (default 120000) - MAX_RETRIES retry attempts per device (default 3) + NOCODB_URL NocoDB base URL e.g. http://192.168.16.130:8080 + NOCODB_TOKEN NocoDB API token + NOCODB_BASE_ID Base ID from browser URL + NOCODB_TABLE_ID Table ID from browser URL + NOCODB_VIEW_ID View ID for the "All" group + NOCODB_VIEW_ID_ELECTRIC View ID for the "Electric" group (falls back to NOCODB_VIEW_ID) + NOCODB_VIEW_ID_GW View ID for the "Gas & Water" group (falls back to NOCODB_VIEW_ID) + PAGE_TIMEOUT ms to wait for page elements (default 90000) + DOWNLOAD_TIMEOUT ms to wait for template gen (default 120000) + UPLOAD_TIMEOUT ms to wait for upload done (default 120000) + MAX_RETRIES retry attempts per device (default 3) + DB_PATH path to SQLite database file (default ./rv50x.db) + MAX_JOBS_MEMORY max completed jobs kept in RAM (default 200) + EXCEL_CACHE_MAX max Excel files cached in RAM (default 20) + EXCEL_CACHE_TTL seconds before cached Excel expires (default 3600) + REACH_TIMEOUT seconds for TCP reachability pre-check (default 3) """ import asyncio @@ -25,6 +32,7 @@ import io import json import os import re +import sqlite3 import threading import urllib.parse import urllib.request @@ -44,11 +52,10 @@ NOCODB_URL = os.environ.get("NOCODB_URL", "http://192.168.16.130:8080" NOCODB_TOKEN = os.environ.get("NOCODB_TOKEN", "eWU_ilelaCtNy1JzC7vf41DokkqFOovcLHM0zVml") NOCODB_BASE_ID = os.environ.get("NOCODB_BASE_ID", "pdq96x915xt4a0m") NOCODB_TABLE_ID = os.environ.get("NOCODB_TABLE_ID", "mkewnr53ahqvnt9") -_view_id = os.environ.get("NOCODB_VIEW_ID", "vwl7qvxo1xclvawz") NOCODB_VIEW_IDS = { - "All": _view_id, - "Electric": _view_id, - "Gas & Water": _view_id, + "All": os.environ.get("NOCODB_VIEW_ID", "vwl7qvxo1xclvawz"), + "Electric": os.environ.get("NOCODB_VIEW_ID_ELECTRIC", os.environ.get("NOCODB_VIEW_ID", "vwl7qvxo1xclvawz")), + "Gas & Water": os.environ.get("NOCODB_VIEW_ID_GW", os.environ.get("NOCODB_VIEW_ID", "vwl7qvxo1xclvawz")), } # ── Local paths ──────────────────────────────────────────────────────────────── @@ -57,6 +64,9 @@ SCRIPT_DIR = Path(__file__).parent DOWNLOAD_DIR = Path(os.environ.get("DOWNLOAD_DIR", str(SCRIPT_DIR / "template_downloads"))) UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", str(SCRIPT_DIR / "template_uploads"))) TEMPLATES_DIR = Path(os.environ.get("TEMPLATES_DIR", str(SCRIPT_DIR / "xml_templates"))) +DB_PATH = Path(os.environ.get("DB_PATH", str(SCRIPT_DIR / "rv50x.db"))) +PRESETS_FILE = SCRIPT_DIR / "at_presets.json" +REACH_TIMEOUT = int(os.environ.get("REACH_TIMEOUT", "3")) # seconds for TCP pre-check DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) @@ -70,11 +80,18 @@ MAX_RETRIES = int(os.environ.get("MAX_RETRIES", "3")) # ── In-memory job store ──────────────────────────────────────────────────────── jobs: dict[str, dict] = {} abort_flags: set[str] = set() # job_ids that have been asked to abort +MAX_JOBS_MEMORY = int(os.environ.get("MAX_JOBS_MEMORY", "200")) # ── AT Terminal job store (separate from Playwright jobs) ────────────────────── at_sessions: dict[str, dict] = {} # session_id → {logs, status, results} SSH_PORT = int(os.environ.get("SSH_PORT", "3223")) +# ── Excel upload cache ───────────────────────────────────────────────────────── +EXCEL_CACHE_MAX = int(os.environ.get("EXCEL_CACHE_MAX", "20")) +EXCEL_CACHE_TTL_S = int(os.environ.get("EXCEL_CACHE_TTL", "3600")) +# Values: {"data": bytes, "ts": datetime} +excel_cache: dict[str, dict] = {} + # ── Auth config ──────────────────────────────────────────────────────────────── APP_USERNAME = os.environ.get("APP_USERNAME", "admin") APP_PASSWORD = os.environ.get("APP_PASSWORD", "changeme") @@ -98,8 +115,165 @@ def require_auth(request: Request): if not token or not _verify_session_cookie(token): raise HTTPException(status_code=302, headers={"Location": "/login"}) + +# ══════════════════════════════════════════════════════════════════════════════ +# SQLite persistence — jobs & AT sessions survive restarts +# ══════════════════════════════════════════════════════════════════════════════ + +def _init_db(): + with sqlite3.connect(DB_PATH) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, + type TEXT, + status TEXT, + device_count INTEGER, + started TEXT, + finished TEXT, + reboot INTEGER DEFAULT 0, + logs TEXT DEFAULT '[]', + results TEXT DEFAULT '[]' + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS at_sessions ( + session_id TEXT PRIMARY KEY, + status TEXT, + started TEXT, + finished TEXT, + device_count INTEGER, + commands TEXT DEFAULT '[]', + logs TEXT DEFAULT '[]', + results TEXT DEFAULT '[]' + ) + """) + conn.commit() + + +def _persist_job(job_id: str): + j = jobs.get(job_id) + if not j: + return + with sqlite3.connect(DB_PATH) as conn: + conn.execute( + """INSERT OR REPLACE INTO jobs + (job_id, type, status, device_count, started, finished, reboot, logs, results) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + job_id, j["type"], j["status"], j["device_count"], + j.get("started"), j.get("finished"), + 1 if j.get("reboot") else 0, + json.dumps(j["logs"]), json.dumps(j["results"]), + ), + ) + conn.commit() + + +def _persist_at_session(session_id: str): + s = at_sessions.get(session_id) + if not s: + return + with sqlite3.connect(DB_PATH) as conn: + conn.execute( + """INSERT OR REPLACE INTO at_sessions + (session_id, status, started, finished, device_count, commands, logs, results) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + session_id, s["status"], s.get("started"), s.get("finished"), + s["device_count"], json.dumps(s["commands"]), + json.dumps(s["logs"]), json.dumps(s["results"]), + ), + ) + conn.commit() + + +def _load_history_from_db(): + """Load the most-recent completed jobs/sessions from SQLite into memory.""" + try: + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + for row in conn.execute( + "SELECT * FROM jobs ORDER BY started DESC LIMIT ?", (MAX_JOBS_MEMORY,) + ): + if row["job_id"] not in jobs: + jobs[row["job_id"]] = { + "type": row["type"], + "status": row["status"], + "device_count": row["device_count"], + "started": row["started"], + "finished": row["finished"], + "reboot": bool(row["reboot"]), + "logs": json.loads(row["logs"]), + "results": json.loads(row["results"]), + } + for row in conn.execute( + "SELECT * FROM at_sessions ORDER BY started DESC LIMIT ?", (MAX_JOBS_MEMORY,) + ): + if row["session_id"] not in at_sessions: + at_sessions[row["session_id"]] = { + "status": row["status"], + "started": row["started"], + "finished": row["finished"], + "device_count": row["device_count"], + "commands": json.loads(row["commands"]), + "logs": json.loads(row["logs"]), + "results": json.loads(row["results"]), + } + except Exception as e: + print(f"[DB] Failed to load history: {e}") + + +def _prune_jobs_memory(): + """Drop oldest completed jobs from memory once we exceed MAX_JOBS_MEMORY.""" + done = sorted( + [(jid, j) for jid, j in jobs.items() if j["status"] == "done"], + key=lambda x: x[1].get("finished", ""), + ) + excess = len(done) - MAX_JOBS_MEMORY + for jid, _ in done[:max(0, excess)]: + del jobs[jid] + + app = FastAPI(title="RV50x Template Manager") +# Initialise DB and reload history on startup +_init_db() +_load_history_from_db() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Excel cache helpers — bounded size + TTL eviction +# ══════════════════════════════════════════════════════════════════════════════ + +def _excel_cache_evict(): + """Remove expired entries, then drop oldest until under EXCEL_CACHE_MAX.""" + now = datetime.now() + expired = [ + k for k, v in excel_cache.items() + if (now - v["ts"]).total_seconds() > EXCEL_CACHE_TTL_S + ] + for k in expired: + del excel_cache[k] + if len(excel_cache) >= EXCEL_CACHE_MAX: + oldest = sorted(excel_cache.items(), key=lambda x: x[1]["ts"]) + for k, _ in oldest[: len(excel_cache) - EXCEL_CACHE_MAX + 1]: + del excel_cache[k] + + +def _excel_cache_set(filename: str, data: bytes): + _excel_cache_evict() + excel_cache[filename] = {"data": data, "ts": datetime.now()} + + +def _excel_cache_get(filename: str) -> bytes | None: + entry = excel_cache.get(filename) + if not entry: + return None + if (datetime.now() - entry["ts"]).total_seconds() > EXCEL_CACHE_TTL_S: + del excel_cache[filename] + return None + return entry["data"] + # ══════════════════════════════════════════════════════════════════════════════ # NocoDB helpers @@ -350,52 +524,77 @@ def _write_report(job_id: str, job: dict, output_dir: Path) -> Path: # Playwright workers (download / upload to modems) # ══════════════════════════════════════════════════════════════════════════════ +def _is_reachable(ip: str, port: int = 443) -> bool: + """Single TCP connect to confirm a device is accepting connections on port 443.""" + import socket + try: + with socket.create_connection((ip, port), timeout=REACH_TIMEOUT): + return True + except OSError: + return False + + +async def _login_and_open_modal(page, device: dict, log) -> bool: + """ + Log in to a device web UI and open the template dialog. + Returns True when the modal is visible and ready, False if it never opened. + Raises on network / timeout errors so callers can handle them uniformly. + """ + ip = device["ip"] + username = device["username"] + password = device["password"] + + await page.goto(f"https://{ip}", wait_until="load") + await page.wait_for_selector('input[name="username"]', state="visible", timeout=PAGE_TIMEOUT) + await page.wait_for_timeout(1000) + await page.click('input[name="username"]', click_count=3) + await page.fill('input[name="username"]', username) + await page.fill('input[name="password"]', password) + await page.wait_for_timeout(800) + login_btn = page.locator('input[name="Login"]') + await login_btn.scroll_into_view_if_needed() + await login_btn.click() + await page.wait_for_selector('#btn_tpl', state="visible", timeout=PAGE_TIMEOUT) + await page.wait_for_function("typeof showTemplateDialog === 'function'", timeout=PAGE_TIMEOUT) + await page.wait_for_timeout(500) + log("Logged in.") + + for _ in range(5): + await page.evaluate("showTemplateDialog()") + await page.wait_for_timeout(1000) + visible = await page.evaluate(""" + () => { + const el = document.getElementById('template_name'); + if (!el) return false; + const form = el.closest('form') || el.closest('div[id]'); + if (!form) return el.offsetParent !== null; + return form.offsetParent !== null; + } + """) + if visible: + return True + + return False + + async def _download_one(device: dict, job_id: str) -> dict: from playwright.async_api import async_playwright, TimeoutError as PWTimeout device_id = device["id"] - ip = device["ip"] - username = device["username"] - password = device["password"] def log(msg): jobs[job_id]["logs"].append(f"[{datetime.now().strftime('%H:%M:%S')}] [{device_id}] {msg}") + if not _is_reachable(device["ip"]): + log(f"✗ Unreachable (no TCP response on :443 within {REACH_TIMEOUT}s)") + return {"id": device_id, "success": False, "message": "Unreachable"} + async with async_playwright() as p: browser = await p.chromium.launch(headless=True) context = await browser.new_context(ignore_https_errors=True, accept_downloads=True) page = await context.new_page() page.set_default_timeout(PAGE_TIMEOUT) try: - await page.goto(f"https://{ip}", wait_until="load") - await page.wait_for_selector('input[name="username"]', state="visible", timeout=PAGE_TIMEOUT) - await page.wait_for_timeout(1000) - await page.click('input[name="username"]', click_count=3) - await page.fill('input[name="username"]', username) - await page.fill('input[name="password"]', password) - await page.wait_for_timeout(800) - login_btn = page.locator('input[name="Login"]') - await login_btn.scroll_into_view_if_needed() - await login_btn.click() - await page.wait_for_selector('#btn_tpl', state="visible", timeout=PAGE_TIMEOUT) - log("Logged in.") - await page.wait_for_function("typeof showTemplateDialog === 'function'", timeout=PAGE_TIMEOUT) - await page.wait_for_timeout(500) - - for _ in range(5): - await page.evaluate("showTemplateDialog()") - await page.wait_for_timeout(1000) - visible = await page.evaluate(""" - () => { - const el = document.getElementById('template_name'); - if (!el) return false; - const form = el.closest('form') || el.closest('div[id]'); - if (!form) return el.offsetParent !== null; - return form.offsetParent !== null; - } - """) - if visible: - break - else: + if not await _login_and_open_modal(page, device, log): return {"id": device_id, "success": False, "message": "Modal did not open"} await page.fill('input[id="template_name"]', device_id) @@ -434,9 +633,6 @@ async def _download_one(device: dict, job_id: str) -> dict: async def _upload_one(device: dict, reboot: bool, job_id: str) -> dict: from playwright.async_api import async_playwright, TimeoutError as PWTimeout device_id = device["id"] - ip = device["ip"] - username = device["username"] - password = device["password"] template_file = UPLOAD_DIR / f"{device_id}.xml" def log(msg): @@ -446,42 +642,17 @@ async def _upload_one(device: dict, reboot: bool, job_id: str) -> dict: log("✗ Template file not found") return {"id": device_id, "success": False, "message": "Template file not found"} + if not _is_reachable(device["ip"]): + log(f"✗ Unreachable (no TCP response on :443 within {REACH_TIMEOUT}s)") + return {"id": device_id, "success": False, "message": "Unreachable"} + async with async_playwright() as p: browser = await p.chromium.launch(headless=True) context = await browser.new_context(ignore_https_errors=True, accept_downloads=True) page = await context.new_page() page.set_default_timeout(PAGE_TIMEOUT) try: - await page.goto(f"https://{ip}", wait_until="load") - await page.wait_for_selector('input[name="username"]', state="visible", timeout=PAGE_TIMEOUT) - await page.wait_for_timeout(1000) - await page.click('input[name="username"]', click_count=3) - await page.fill('input[name="username"]', username) - await page.fill('input[name="password"]', password) - await page.wait_for_timeout(800) - login_btn = page.locator('input[name="Login"]') - await login_btn.scroll_into_view_if_needed() - await login_btn.click() - await page.wait_for_selector('#btn_tpl', state="visible", timeout=PAGE_TIMEOUT) - await page.wait_for_function("typeof showTemplateDialog === 'function'", timeout=PAGE_TIMEOUT) - await page.wait_for_timeout(500) - log("Logged in.") - - for _ in range(5): - await page.evaluate("showTemplateDialog()") - await page.wait_for_timeout(1000) - visible = await page.evaluate(""" - () => { - const el = document.getElementById('template_name'); - if (!el) return false; - const form = el.closest('form') || el.closest('div[id]'); - if (!form) return el.offsetParent !== null; - return form.offsetParent !== null; - } - """) - if visible: - break - else: + if not await _login_and_open_modal(page, device, log): return {"id": device_id, "success": False, "message": "Modal did not open"} await page.locator('input[id="template_filename"]').set_input_files(str(template_file)) @@ -619,6 +790,8 @@ async def run_download_job(job_id: str, devices: list, concurrency: int): jobs[job_id]["logs"].append( f"[{datetime.now().strftime('%H:%M:%S')}] Report saved → {report_path.name}" ) + _persist_job(job_id) + _prune_jobs_memory() async def run_upload_job(job_id: str, devices: list, concurrency: int, reboot: bool): @@ -638,6 +811,8 @@ async def run_upload_job(job_id: str, devices: list, concurrency: int, reboot: b jobs[job_id]["logs"].append( f"[{datetime.now().strftime('%H:%M:%S')}] Report saved → {report_path.name}" ) + _persist_job(job_id) + _prune_jobs_memory() # ══════════════════════════════════════════════════════════════════════════════ @@ -659,6 +834,11 @@ class BuildRequest(BaseModel): default_value: str = "NOTSET" +class PresetRequest(BaseModel): + name: str + commands: list[str] + + # ══════════════════════════════════════════════════════════════════════════════ # API routes — devices & jobs (unchanged) # ══════════════════════════════════════════════════════════════════════════════ @@ -798,8 +978,7 @@ async def xmlbuilder_upload_excel(request: Request, file: UploadFile = File(...) columns = [c.strip().strip('{}') for c in df.columns] hostname_col = next((c for c in df.columns if "hostname" in c.lower()), None) hostnames = df[hostname_col].dropna().astype(str).tolist() if hostname_col else [] - # Store bytes in a simple cache keyed by filename - excel_cache[file.filename] = contents + _excel_cache_set(file.filename, contents) return { "filename": file.filename, "columns": columns, @@ -810,10 +989,6 @@ async def xmlbuilder_upload_excel(request: Request, file: UploadFile = File(...) return {"error": str(e)} -# Simple in-memory cache for uploaded Excel files (cleared on restart) -excel_cache: dict[str, bytes] = {} - - @app.get("/api/xmlbuilder/templates") def xmlbuilder_list_templates(request: Request, _auth=Depends(require_auth)): """List XML template files stored in xml_templates/.""" @@ -876,11 +1051,11 @@ async def xmlbuilder_generate_excel( # Get Excel bytes — from fresh upload or cache if excel_file: excel_bytes = await excel_file.read() - excel_cache[excel_filename] = excel_bytes - elif excel_filename in excel_cache: - excel_bytes = excel_cache[excel_filename] + _excel_cache_set(excel_filename, excel_bytes) else: - return {"error": "Excel file not found. Please re-upload it."} + excel_bytes = _excel_cache_get(excel_filename) + if excel_bytes is None: + return {"error": "Excel file not found or session expired. Please re-upload it."} host_list = json.loads(hostnames) results = build_xmls_from_excel(excel_bytes, template, host_list, default_value) @@ -1076,9 +1251,10 @@ def _run_at_session(session_id: str, devices: list, commands: list, ssh_username d = futures[future] results.append({"id": d["id"], "success": False, "message": str(e), "outputs": []}) - at_sessions[session_id]["results"] = results - at_sessions[session_id]["status"] = "done" + at_sessions[session_id]["results"] = results + at_sessions[session_id]["status"] = "done" at_sessions[session_id]["finished"] = datetime.now().isoformat() + _persist_at_session(session_id) @app.post("/api/at/send") @@ -1116,6 +1292,52 @@ async def at_send(request: Request, req: ATRequest, background_tasks: Background return {"session_id": session_id, "device_count": len(devices)} +# ── AT preset helpers ────────────────────────────────────────────────────────── + +def _read_presets() -> dict: + if PRESETS_FILE.exists(): + try: + return json.loads(PRESETS_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + return {} + + +def _write_presets(presets: dict): + PRESETS_FILE.write_text(json.dumps(presets, indent=2, ensure_ascii=False), encoding="utf-8") + + +# Preset routes MUST be registered before the {session_id} wildcard routes below. +@app.get("/api/at/presets") +def at_list_presets(request: Request, _auth=Depends(require_auth)): + presets = _read_presets() + return {"presets": [{"name": k, "commands": v} for k, v in presets.items()]} + + +@app.post("/api/at/presets") +def at_save_preset(request: Request, req: PresetRequest, _auth=Depends(require_auth)): + name = req.name.strip() + commands = [c.strip() for c in req.commands if c.strip()] + if not name: + return {"error": "Preset name cannot be empty"} + if not commands: + return {"error": "No commands provided"} + presets = _read_presets() + presets[name] = commands + _write_presets(presets) + return {"saved": True, "name": name, "commands": commands} + + +@app.delete("/api/at/presets/{name}") +def at_delete_preset(request: Request, name: str, _auth=Depends(require_auth)): + presets = _read_presets() + if name not in presets: + return {"error": "Preset not found"} + del presets[name] + _write_presets(presets) + return {"deleted": True, "name": name} + + @app.get("/api/at/{session_id}/stream") async def at_stream(request: Request, session_id: str, from_line: int = 0, _auth=Depends(require_auth)): """SSE stream for AT session logs — same pattern as job streaming.""" diff --git a/docker-compose.yml b/docker-compose.yml index e4ef260..76a0146 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ services: - /opt/rv50x-manager/template_uploads:/data/template_uploads - /opt/rv50x-manager/xml_templates:/data/xml_templates - /opt/rv50x-manager/certs:/certs:ro # SSL certificates (read-only) + - /opt/rv50x-manager/rv50x.db:/app/rv50x.db # job history (SQLite) + - /opt/rv50x-manager/at_presets.json:/app/at_presets.json # AT presets environment: # ── NocoDB connection ────────────────────────────────────────────── # To use the built-in NocoDB from this stack, set NOCODB_URL to: @@ -44,7 +46,9 @@ services: NOCODB_TOKEN: ${NOCODB_TOKEN} NOCODB_BASE_ID: ${NOCODB_BASE_ID} NOCODB_TABLE_ID: ${NOCODB_TABLE_ID} - NOCODB_VIEW_ID: ${NOCODB_VIEW_ID} + NOCODB_VIEW_ID: ${NOCODB_VIEW_ID} + NOCODB_VIEW_ID_ELECTRIC: ${NOCODB_VIEW_ID_ELECTRIC:-} + NOCODB_VIEW_ID_GW: ${NOCODB_VIEW_ID_GW:-} # ── SSL certificate paths (inside the container) ─────────────────── SSL_CERT: /certs/cert.pem SSL_KEY: /certs/key.pem diff --git a/index.html b/index.html index fef9e50..4e5105c 100644 --- a/index.html +++ b/index.html @@ -238,6 +238,12 @@ .at-msg.info { color: var(--text2); } .at-sep { border: none; border-top: 1px solid var(--border); margin: 8px 0; } .at-results-bar { display: flex; gap: 20px; padding: 8px 20px; background: var(--bg2); border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 11px; } + .preset-strip { display: flex; flex-wrap: wrap; gap: 5px; min-height: 24px; } + .preset-pill { display: inline-flex; align-items: center; background: rgba(255,109,0,0.08); border: 1px solid rgba(255,109,0,0.3); border-radius: 3px; font-family: var(--font-mono); font-size: 10px; } + .preset-load { background: none; border: none; color: var(--orange); cursor: pointer; font-family: var(--font-mono); font-size: 10px; padding: 3px 6px; letter-spacing: 0.3px; } + .preset-load:hover { color: var(--text); } + .preset-del { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 13px; line-height: 1; padding: 2px 5px 2px 0; } + .preset-del:hover { color: var(--red); } .btn-orange { background: rgba(255,109,0,0.12); border: 1px solid rgba(255,109,0,0.5); color: var(--orange); } .btn-orange:hover:not(:disabled) { background: rgba(255,109,0,0.2); box-shadow: 0 0 16px rgba(255,109,0,0.2); } @@ -737,6 +743,16 @@ + +
+
+ PRESETS + +
+
+ No presets saved +
+
@@ -1449,6 +1465,64 @@ async function atSend() { }; } +// ── AT Presets ──────────────────────────────────────────────────────────────── +async function atLoadPresets() { + try { + const res = await fetch('/api/at/presets'); + const data = await res.json(); + atRenderPresets(data.presets || []); + } catch(e) {} +} + +function atRenderPresets(presets) { + const strip = document.getElementById('at-preset-strip'); + if (!presets.length) { + strip.innerHTML = 'No presets saved'; + return; + } + strip.innerHTML = presets.map(p => ` + + + + `).join(''); +} + +function atApplyPreset(commands) { + document.getElementById('at-cmd-input').value = commands.join('\n'); +} + +async function atSavePreset() { + const rawCmds = document.getElementById('at-cmd-input').value + .split('\n').map(l => l.trim()).filter(l => l); + if (!rawCmds.length) { alert('Enter at least one command in the textarea first.'); return; } + const name = prompt('Preset name:'); + if (!name || !name.trim()) return; + try { + const res = await fetch('/api/at/presets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim(), commands: rawCmds }), + }); + const data = await res.json(); + if (data.error) { alert('Error: ' + data.error); return; } + await atLoadPresets(); + } catch(e) { alert('Request failed: ' + e.message); } +} + +async function atDeletePreset(name) { + if (!confirm(`Delete preset "${name}"?`)) return; + try { + const res = await fetch(`/api/at/presets/${encodeURIComponent(name)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.error) { alert('Error: ' + data.error); return; } + await atLoadPresets(); + } catch(e) { alert('Request failed: ' + e.message); } +} + // ── Tab switching ────────────────────────────────────────────────────────────── function switchTab(name,btn){ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active')); @@ -1457,7 +1531,7 @@ function switchTab(name,btn){ document.getElementById(`tab-${name}`).classList.add('active'); if(name==='history') refreshJobList(); if(name==='builder'){ loadBuilderTemplates(); if(builderSource==='nocodb') loadBuilderHostnames(); } - if(name==='at'){ atDevices=[...allDevices].sort((a,b)=>a.id.localeCompare(b.id)); atFiltered=atDevices.slice(); atRenderDeviceList(); } + if(name==='at'){ atDevices=[...allDevices].sort((a,b)=>a.id.localeCompare(b.id)); atFiltered=atDevices.slice(); atRenderDeviceList(); atLoadPresets(); } } function switchTabById(name){ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));