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:
@@ -2,3 +2,8 @@
|
|||||||
certs/
|
certs/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
rv50x.db
|
||||||
|
at_presets.json
|
||||||
|
template_downloads/
|
||||||
|
template_uploads/
|
||||||
|
template_backups/
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ Environment variables (set in .env or docker-compose.yml):
|
|||||||
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
|
||||||
|
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)
|
PAGE_TIMEOUT ms to wait for page elements (default 90000)
|
||||||
DOWNLOAD_TIMEOUT ms to wait for template gen (default 120000)
|
DOWNLOAD_TIMEOUT ms to wait for template gen (default 120000)
|
||||||
UPLOAD_TIMEOUT ms to wait for upload done (default 120000)
|
UPLOAD_TIMEOUT ms to wait for upload done (default 120000)
|
||||||
MAX_RETRIES retry attempts per device (default 3)
|
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,22 +524,26 @@ def _write_report(job_id: str, job: dict, output_dir: Path) -> Path:
|
|||||||
# Playwright workers (download / upload to modems)
|
# Playwright workers (download / upload to modems)
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async def _download_one(device: dict, job_id: str) -> dict:
|
def _is_reachable(ip: str, port: int = 443) -> bool:
|
||||||
from playwright.async_api import async_playwright, TimeoutError as PWTimeout
|
"""Single TCP connect to confirm a device is accepting connections on port 443."""
|
||||||
device_id = device["id"]
|
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"]
|
ip = device["ip"]
|
||||||
username = device["username"]
|
username = device["username"]
|
||||||
password = device["password"]
|
password = device["password"]
|
||||||
|
|
||||||
def log(msg):
|
|
||||||
jobs[job_id]["logs"].append(f"[{datetime.now().strftime('%H:%M:%S')}] [{device_id}] {msg}")
|
|
||||||
|
|
||||||
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.goto(f"https://{ip}", wait_until="load")
|
||||||
await page.wait_for_selector('input[name="username"]', state="visible", timeout=PAGE_TIMEOUT)
|
await page.wait_for_selector('input[name="username"]', state="visible", timeout=PAGE_TIMEOUT)
|
||||||
await page.wait_for_timeout(1000)
|
await page.wait_for_timeout(1000)
|
||||||
@@ -377,9 +555,9 @@ async def _download_one(device: dict, job_id: str) -> dict:
|
|||||||
await login_btn.scroll_into_view_if_needed()
|
await login_btn.scroll_into_view_if_needed()
|
||||||
await login_btn.click()
|
await login_btn.click()
|
||||||
await page.wait_for_selector('#btn_tpl', state="visible", timeout=PAGE_TIMEOUT)
|
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_function("typeof showTemplateDialog === 'function'", timeout=PAGE_TIMEOUT)
|
||||||
await page.wait_for_timeout(500)
|
await page.wait_for_timeout(500)
|
||||||
|
log("Logged in.")
|
||||||
|
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
await page.evaluate("showTemplateDialog()")
|
await page.evaluate("showTemplateDialog()")
|
||||||
@@ -394,8 +572,29 @@ async def _download_one(device: dict, job_id: str) -> dict:
|
|||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
if visible:
|
if visible:
|
||||||
break
|
return True
|
||||||
else:
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
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:
|
||||||
|
if not await _login_and_open_modal(page, device, log):
|
||||||
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)
|
||||||
@@ -1079,6 +1254,7 @@ def _run_at_session(session_id: str, devices: list, commands: list, ssh_username
|
|||||||
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."""
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -45,6 +47,8 @@ services:
|
|||||||
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
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user