Add reliability enhancements, reachability pre-check, and AT presets

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 <noreply@anthropic.com>
This commit is contained in:
D Stephenson
2026-04-23 20:37:17 +00:00
parent 6c21525b79
commit cd819002b4
4 changed files with 398 additions and 93 deletions
+5
View File
@@ -2,3 +2,8 @@
certs/ certs/
__pycache__/ __pycache__/
*.pyc *.pyc
rv50x.db
at_presets.json
template_downloads/
template_uploads/
template_backups/
+313 -91
View File
@@ -8,15 +8,22 @@ Run via Docker:
docker-compose up -d docker-compose up -d
Environment variables (set in .env or docker-compose.yml): Environment variables (set in .env or docker-compose.yml):
NOCODB_URL NocoDB base URL e.g. http://192.168.16.130:8080 NOCODB_URL NocoDB base URL e.g. http://192.168.16.130:8080
NOCODB_TOKEN NocoDB API token NOCODB_TOKEN NocoDB API token
NOCODB_BASE_ID Base ID from browser URL NOCODB_BASE_ID Base ID from browser URL
NOCODB_TABLE_ID Table ID from browser URL NOCODB_TABLE_ID Table ID from browser URL
NOCODB_VIEW_ID View ID from browser URL (the "All" view) NOCODB_VIEW_ID View ID for the "All" group
PAGE_TIMEOUT ms to wait for page elements (default 90000) NOCODB_VIEW_ID_ELECTRIC View ID for the "Electric" group (falls back to NOCODB_VIEW_ID)
DOWNLOAD_TIMEOUT ms to wait for template gen (default 120000) NOCODB_VIEW_ID_GW View ID for the "Gas & Water" group (falls back to NOCODB_VIEW_ID)
UPLOAD_TIMEOUT ms to wait for upload done (default 120000) PAGE_TIMEOUT ms to wait for page elements (default 90000)
MAX_RETRIES retry attempts per device (default 3) 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 import asyncio
@@ -25,6 +32,7 @@ import io
import json import json
import os import os
import re import re
import sqlite3
import threading import threading
import urllib.parse import urllib.parse
import urllib.request 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_TOKEN = os.environ.get("NOCODB_TOKEN", "eWU_ilelaCtNy1JzC7vf41DokkqFOovcLHM0zVml")
NOCODB_BASE_ID = os.environ.get("NOCODB_BASE_ID", "pdq96x915xt4a0m") NOCODB_BASE_ID = os.environ.get("NOCODB_BASE_ID", "pdq96x915xt4a0m")
NOCODB_TABLE_ID = os.environ.get("NOCODB_TABLE_ID", "mkewnr53ahqvnt9") NOCODB_TABLE_ID = os.environ.get("NOCODB_TABLE_ID", "mkewnr53ahqvnt9")
_view_id = os.environ.get("NOCODB_VIEW_ID", "vwl7qvxo1xclvawz")
NOCODB_VIEW_IDS = { NOCODB_VIEW_IDS = {
"All": _view_id, "All": os.environ.get("NOCODB_VIEW_ID", "vwl7qvxo1xclvawz"),
"Electric": _view_id, "Electric": os.environ.get("NOCODB_VIEW_ID_ELECTRIC", os.environ.get("NOCODB_VIEW_ID", "vwl7qvxo1xclvawz")),
"Gas & Water": _view_id, "Gas & Water": os.environ.get("NOCODB_VIEW_ID_GW", os.environ.get("NOCODB_VIEW_ID", "vwl7qvxo1xclvawz")),
} }
# ── Local paths ──────────────────────────────────────────────────────────────── # ── Local paths ────────────────────────────────────────────────────────────────
@@ -57,6 +64,9 @@ SCRIPT_DIR = Path(__file__).parent
DOWNLOAD_DIR = Path(os.environ.get("DOWNLOAD_DIR", str(SCRIPT_DIR / "template_downloads"))) 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"))) 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"))) 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) DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
TEMPLATES_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 ──────────────────────────────────────────────────────── # ── In-memory job store ────────────────────────────────────────────────────────
jobs: dict[str, dict] = {} jobs: dict[str, dict] = {}
abort_flags: set[str] = set() # job_ids that have been asked to abort 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 Terminal job store (separate from Playwright jobs) ──────────────────────
at_sessions: dict[str, dict] = {} # session_id → {logs, status, results} at_sessions: dict[str, dict] = {} # session_id → {logs, status, results}
SSH_PORT = int(os.environ.get("SSH_PORT", "3223")) 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 ──────────────────────────────────────────────────────────────── # ── Auth config ────────────────────────────────────────────────────────────────
APP_USERNAME = os.environ.get("APP_USERNAME", "admin") APP_USERNAME = os.environ.get("APP_USERNAME", "admin")
APP_PASSWORD = os.environ.get("APP_PASSWORD", "changeme") 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): if not token or not _verify_session_cookie(token):
raise HTTPException(status_code=302, headers={"Location": "/login"}) 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") 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 # NocoDB helpers
@@ -350,52 +524,77 @@ def _write_report(job_id: str, job: dict, output_dir: Path) -> Path:
# Playwright workers (download / upload to modems) # 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: async def _download_one(device: dict, job_id: str) -> dict:
from playwright.async_api import async_playwright, TimeoutError as PWTimeout from playwright.async_api import async_playwright, TimeoutError as PWTimeout
device_id = device["id"] device_id = device["id"]
ip = device["ip"]
username = device["username"]
password = device["password"]
def log(msg): def log(msg):
jobs[job_id]["logs"].append(f"[{datetime.now().strftime('%H:%M:%S')}] [{device_id}] {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: async with async_playwright() as p:
browser = await p.chromium.launch(headless=True) browser = await p.chromium.launch(headless=True)
context = await browser.new_context(ignore_https_errors=True, accept_downloads=True) context = await browser.new_context(ignore_https_errors=True, accept_downloads=True)
page = await context.new_page() page = await context.new_page()
page.set_default_timeout(PAGE_TIMEOUT) page.set_default_timeout(PAGE_TIMEOUT)
try: try:
await page.goto(f"https://{ip}", wait_until="load") if not await _login_and_open_modal(page, device, log):
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:
return {"id": device_id, "success": False, "message": "Modal did not open"} return {"id": device_id, "success": False, "message": "Modal did not open"}
await page.fill('input[id="template_name"]', device_id) 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: async def _upload_one(device: dict, reboot: bool, job_id: str) -> dict:
from playwright.async_api import async_playwright, TimeoutError as PWTimeout from playwright.async_api import async_playwright, TimeoutError as PWTimeout
device_id = device["id"] device_id = device["id"]
ip = device["ip"]
username = device["username"]
password = device["password"]
template_file = UPLOAD_DIR / f"{device_id}.xml" template_file = UPLOAD_DIR / f"{device_id}.xml"
def log(msg): def log(msg):
@@ -446,42 +642,17 @@ async def _upload_one(device: dict, reboot: bool, job_id: str) -> dict:
log("✗ Template file not found") log("✗ Template file not found")
return {"id": device_id, "success": False, "message": "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: async with async_playwright() as p:
browser = await p.chromium.launch(headless=True) browser = await p.chromium.launch(headless=True)
context = await browser.new_context(ignore_https_errors=True, accept_downloads=True) context = await browser.new_context(ignore_https_errors=True, accept_downloads=True)
page = await context.new_page() page = await context.new_page()
page.set_default_timeout(PAGE_TIMEOUT) page.set_default_timeout(PAGE_TIMEOUT)
try: try:
await page.goto(f"https://{ip}", wait_until="load") if not await _login_and_open_modal(page, device, log):
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:
return {"id": device_id, "success": False, "message": "Modal did not open"} return {"id": device_id, "success": False, "message": "Modal did not open"}
await page.locator('input[id="template_filename"]').set_input_files(str(template_file)) 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( jobs[job_id]["logs"].append(
f"[{datetime.now().strftime('%H:%M:%S')}] Report saved → {report_path.name}" 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): 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( jobs[job_id]["logs"].append(
f"[{datetime.now().strftime('%H:%M:%S')}] Report saved → {report_path.name}" 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" default_value: str = "NOTSET"
class PresetRequest(BaseModel):
name: str
commands: list[str]
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
# API routes — devices & jobs (unchanged) # 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] columns = [c.strip().strip('{}') for c in df.columns]
hostname_col = next((c for c in df.columns if "hostname" in c.lower()), None) 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 [] hostnames = df[hostname_col].dropna().astype(str).tolist() if hostname_col else []
# Store bytes in a simple cache keyed by filename _excel_cache_set(file.filename, contents)
excel_cache[file.filename] = contents
return { return {
"filename": file.filename, "filename": file.filename,
"columns": columns, "columns": columns,
@@ -810,10 +989,6 @@ async def xmlbuilder_upload_excel(request: Request, file: UploadFile = File(...)
return {"error": str(e)} 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") @app.get("/api/xmlbuilder/templates")
def xmlbuilder_list_templates(request: Request, _auth=Depends(require_auth)): def xmlbuilder_list_templates(request: Request, _auth=Depends(require_auth)):
"""List XML template files stored in xml_templates/.""" """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 # Get Excel bytes — from fresh upload or cache
if excel_file: if excel_file:
excel_bytes = await excel_file.read() excel_bytes = await excel_file.read()
excel_cache[excel_filename] = excel_bytes _excel_cache_set(excel_filename, excel_bytes)
elif excel_filename in excel_cache:
excel_bytes = excel_cache[excel_filename]
else: 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) host_list = json.loads(hostnames)
results = build_xmls_from_excel(excel_bytes, template, host_list, default_value) 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] d = futures[future]
results.append({"id": d["id"], "success": False, "message": str(e), "outputs": []}) results.append({"id": d["id"], "success": False, "message": str(e), "outputs": []})
at_sessions[session_id]["results"] = results at_sessions[session_id]["results"] = results
at_sessions[session_id]["status"] = "done" at_sessions[session_id]["status"] = "done"
at_sessions[session_id]["finished"] = datetime.now().isoformat() at_sessions[session_id]["finished"] = datetime.now().isoformat()
_persist_at_session(session_id)
@app.post("/api/at/send") @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)} 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") @app.get("/api/at/{session_id}/stream")
async def at_stream(request: Request, session_id: str, from_line: int = 0, _auth=Depends(require_auth)): 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.""" """SSE stream for AT session logs — same pattern as job streaming."""
+5 -1
View File
@@ -35,6 +35,8 @@ services:
- /opt/rv50x-manager/template_uploads:/data/template_uploads - /opt/rv50x-manager/template_uploads:/data/template_uploads
- /opt/rv50x-manager/xml_templates:/data/xml_templates - /opt/rv50x-manager/xml_templates:/data/xml_templates
- /opt/rv50x-manager/certs:/certs:ro # SSL certificates (read-only) - /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: environment:
# ── NocoDB connection ────────────────────────────────────────────── # ── NocoDB connection ──────────────────────────────────────────────
# To use the built-in NocoDB from this stack, set NOCODB_URL to: # To use the built-in NocoDB from this stack, set NOCODB_URL to:
@@ -44,7 +46,9 @@ services:
NOCODB_TOKEN: ${NOCODB_TOKEN} NOCODB_TOKEN: ${NOCODB_TOKEN}
NOCODB_BASE_ID: ${NOCODB_BASE_ID} NOCODB_BASE_ID: ${NOCODB_BASE_ID}
NOCODB_TABLE_ID: ${NOCODB_TABLE_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 certificate paths (inside the container) ───────────────────
SSL_CERT: /certs/cert.pem SSL_CERT: /certs/cert.pem
SSL_KEY: /certs/key.pem SSL_KEY: /certs/key.pem
+75 -1
View File
@@ -238,6 +238,12 @@
.at-msg.info { color: var(--text2); } .at-msg.info { color: var(--text2); }
.at-sep { border: none; border-top: 1px solid var(--border); margin: 8px 0; } .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; } .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 { 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); } .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 @@
<button class="at-quick" onclick="atSetCmd('AT*WWAN1HOMEPAGEURL?')">APN URL</button> <button class="at-quick" onclick="atSetCmd('AT*WWAN1HOMEPAGEURL?')">APN URL</button>
<button class="at-quick" onclick="atSetCmd('ATI1\nAT*CELLINFO2?\nAT*NETRSSI?')">📶 signal bundle</button> <button class="at-quick" onclick="atSetCmd('ATI1\nAT*CELLINFO2?\nAT*NETRSSI?')">📶 signal bundle</button>
</div> </div>
<!-- Saved presets -->
<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3);letter-spacing:1.5px">PRESETS</span>
<button class="at-quick" onclick="atSavePreset()" style="margin-left:auto;padding:2px 8px">+ SAVE CURRENT</button>
</div>
<div class="preset-strip" id="at-preset-strip">
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3)">No presets saved</span>
</div>
</div>
<div style="display:flex;gap:10px;margin-top:10px;align-items:center"> <div style="display:flex;gap:10px;margin-top:10px;align-items:center">
<button class="btn btn-orange" id="at-send-btn" onclick="atSend()">▶ SEND</button> <button class="btn btn-orange" id="at-send-btn" onclick="atSend()">▶ SEND</button>
<button class="btn btn-neutral btn-sm" onclick="atClearTerminal()">CLEAR LOG</button> <button class="btn btn-neutral btn-sm" onclick="atClearTerminal()">CLEAR LOG</button>
@@ -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 = '<span style="font-family:\'Share Tech Mono\',monospace;font-size:10px;color:var(--text3)">No presets saved</span>';
return;
}
strip.innerHTML = presets.map(p => `
<span class="preset-pill">
<button class="preset-load"
onclick="atApplyPreset(${JSON.stringify(p.commands)})"
title="${escHtml(p.commands.join(' | '))}">${escHtml(p.name)}</button>
<button class="preset-del"
onclick="atDeletePreset(${JSON.stringify(p.name)})"
title="Delete preset">×</button>
</span>`).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 ────────────────────────────────────────────────────────────── // ── Tab switching ──────────────────────────────────────────────────────────────
function switchTab(name,btn){ function switchTab(name,btn){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active')); document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
@@ -1457,7 +1531,7 @@ function switchTab(name,btn){
document.getElementById(`tab-${name}`).classList.add('active'); document.getElementById(`tab-${name}`).classList.add('active');
if(name==='history') refreshJobList(); if(name==='history') refreshJobList();
if(name==='builder'){ loadBuilderTemplates(); if(builderSource==='nocodb') loadBuilderHostnames(); } 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){ function switchTabById(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active')); document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));