Initial release — Salus by Stranto v1.6.1.0
FastAPI/Jinja2 web app for viewing and rebooting TP-Link Omada APs across all sites. Authentik OIDC auth, SQLite audit log, Docker deploy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
118
app/auth.py
Normal file
118
app/auth.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
import base64
|
||||
import secrets
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTHENTIK_ISSUER = os.getenv("AUTHENTIK_ISSUER", "").rstrip("/")
|
||||
AUTHENTIK_CLIENT_ID = os.getenv("AUTHENTIK_CLIENT_ID", "")
|
||||
AUTHENTIK_CLIENT_SECRET = os.getenv("AUTHENTIK_CLIENT_SECRET", "")
|
||||
AUTHENTIK_REDIRECT_URI = os.getenv("AUTHENTIK_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||
SESSION_SECRET_KEY = os.getenv("SESSION_SECRET_KEY", "changeme_please_set_in_env")
|
||||
|
||||
_oidc_config: Optional[dict] = None
|
||||
|
||||
|
||||
async def get_oidc_config() -> dict:
|
||||
global _oidc_config
|
||||
if _oidc_config is None:
|
||||
url = f"{AUTHENTIK_ISSUER}/.well-known/openid-configuration"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
_oidc_config = resp.json()
|
||||
logger.info("OIDC config loaded from %s", url)
|
||||
return _oidc_config
|
||||
|
||||
|
||||
async def build_login_url(request: Request) -> str:
|
||||
config = await get_oidc_config()
|
||||
state = secrets.token_urlsafe(16)
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
request.session["oauth_state"] = state
|
||||
request.session["oauth_nonce"] = nonce
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": AUTHENTIK_CLIENT_ID,
|
||||
"redirect_uri": AUTHENTIK_REDIRECT_URI,
|
||||
"scope": "openid profile email",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
}
|
||||
return f"{config['authorization_endpoint']}?{urlencode(params)}"
|
||||
|
||||
|
||||
def _decode_jwt_payload(token: str) -> dict:
|
||||
"""Decode JWT payload without signature verification (Authentik is trusted)."""
|
||||
try:
|
||||
payload_b64 = token.split(".")[1]
|
||||
padding = "=" * (4 - len(payload_b64) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
async def exchange_code(request: Request) -> dict:
|
||||
"""Handle OIDC callback: exchange code for tokens, extract user info."""
|
||||
config = await get_oidc_config()
|
||||
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
error = request.query_params.get("error")
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=f"OAuth error: {error}")
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="Missing authorization code")
|
||||
if state != request.session.get("oauth_state"):
|
||||
raise HTTPException(status_code=400, detail="OAuth state mismatch")
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
token_resp = await client.post(
|
||||
config["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": AUTHENTIK_REDIRECT_URI,
|
||||
"client_id": AUTHENTIK_CLIENT_ID,
|
||||
"client_secret": AUTHENTIK_CLIENT_SECRET,
|
||||
},
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
tokens = token_resp.json()
|
||||
|
||||
claims = _decode_jwt_payload(tokens.get("id_token", ""))
|
||||
|
||||
user = {
|
||||
"username": claims.get("preferred_username") or claims.get("sub", "unknown"),
|
||||
"email": claims.get("email", ""),
|
||||
"name": claims.get("name", ""),
|
||||
"sub": claims.get("sub", ""),
|
||||
}
|
||||
request.session["user"] = user
|
||||
request.session.pop("oauth_state", None)
|
||||
request.session.pop("oauth_nonce", None)
|
||||
logger.info("User logged in: %s", user["username"])
|
||||
return user
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> Optional[dict]:
|
||||
return request.session.get("user")
|
||||
|
||||
|
||||
def require_auth(request: Request) -> dict:
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=302,
|
||||
headers={"Location": "/auth/login"},
|
||||
)
|
||||
return user
|
||||
36
app/database.py
Normal file
36
app/database.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
import datetime
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
DATABASE_URL = f"sqlite:///{os.getenv('DB_PATH', '/data/audit.db')}"
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class RebootLog(Base):
|
||||
__tablename__ = "reboot_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
||||
username = Column(String, nullable=False)
|
||||
user_email = Column(String, nullable=False)
|
||||
ap_name = Column(String, nullable=False)
|
||||
ap_mac = Column(String, nullable=False)
|
||||
ap_ip = Column(String, nullable=True)
|
||||
result = Column(String, nullable=False) # "success" | "error"
|
||||
error_message = Column(String, nullable=True)
|
||||
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
308
app/main.py
Normal file
308
app/main.py
Normal file
@@ -0,0 +1,308 @@
|
||||
import csv
|
||||
import io
|
||||
import datetime
|
||||
import secrets
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
# Load .env before any module-level os.getenv() calls in auth/omada/database
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import FastAPI, Request, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import init_db, get_db, RebootLog
|
||||
from app.auth import (
|
||||
SESSION_SECRET_KEY,
|
||||
build_login_url,
|
||||
exchange_code,
|
||||
get_current_user,
|
||||
)
|
||||
from app.omada import omada_client
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Salus by Stranto", docs_url=None, redoc_url=None)
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=SESSION_SECRET_KEY,
|
||||
max_age=86400,
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
)
|
||||
|
||||
BASE_DIR = os.path.dirname(__file__)
|
||||
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
|
||||
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
init_db()
|
||||
logger.info("Database initialized")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/debug/sites")
|
||||
async def debug_sites():
|
||||
"""Temporary: lists all accessible Omada sites."""
|
||||
try:
|
||||
sites = await omada_client.get_all_sites()
|
||||
return {"sites": [{"name": k, "key": v} for k, v in sites.items()]}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _redirect_login():
|
||||
return RedirectResponse("/auth/login", status_code=302)
|
||||
|
||||
|
||||
def _get_user_or_redirect(request: Request):
|
||||
"""Return user dict or a RedirectResponse. Callers must check the type."""
|
||||
return get_current_user(request)
|
||||
|
||||
|
||||
def _csrf_token(request: Request) -> str:
|
||||
if "csrf_token" not in request.session:
|
||||
request.session["csrf_token"] = secrets.token_hex(32)
|
||||
return request.session["csrf_token"]
|
||||
|
||||
|
||||
def _verify_csrf(request: Request, token: str):
|
||||
expected = request.session.get("csrf_token")
|
||||
if not expected or not secrets.compare_digest(expected, token):
|
||||
raise HTTPException(status_code=403, detail="CSRF token invalid")
|
||||
|
||||
|
||||
AUTH_DISABLED = os.getenv("AUTH_DISABLED", "false").strip().lower() == "true"
|
||||
|
||||
DEV_USER = {"username": "dev-user", "email": "dev@localhost", "name": "Dev User", "sub": "dev"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/auth/login", response_class=HTMLResponse)
|
||||
async def login(request: Request):
|
||||
if AUTH_DISABLED:
|
||||
request.session["user"] = DEV_USER
|
||||
return RedirectResponse("/", status_code=302)
|
||||
if get_current_user(request):
|
||||
return RedirectResponse("/", status_code=302)
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/auth/login/start")
|
||||
async def login_start(request: Request):
|
||||
if AUTH_DISABLED:
|
||||
request.session["user"] = DEV_USER
|
||||
return RedirectResponse("/", status_code=302)
|
||||
url = await build_login_url(request)
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@app.get("/auth/callback")
|
||||
async def callback(request: Request):
|
||||
await exchange_code(request)
|
||||
return RedirectResponse("/", status_code=302)
|
||||
|
||||
|
||||
@app.get("/auth/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/auth/login", status_code=302)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
|
||||
error: Optional[str] = None
|
||||
aps: list = []
|
||||
try:
|
||||
aps = await omada_client.get_aps()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to fetch APs: %s", exc)
|
||||
error = str(exc)
|
||||
|
||||
csrf = _csrf_token(request)
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"aps": aps,
|
||||
"error": error,
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/audit", response_class=HTMLResponse)
|
||||
async def audit_page(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
username: str = "",
|
||||
ap_name: str = "",
|
||||
):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
|
||||
query = db.query(RebootLog).order_by(RebootLog.timestamp.desc())
|
||||
if username.strip():
|
||||
query = query.filter(RebootLog.username.icontains(username.strip()))
|
||||
if ap_name.strip():
|
||||
query = query.filter(RebootLog.ap_name.icontains(ap_name.strip()))
|
||||
logs = query.all()
|
||||
|
||||
return templates.TemplateResponse("audit.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"logs": logs,
|
||||
"filter_username": username,
|
||||
"filter_ap": ap_name,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/audit/export")
|
||||
async def export_csv(request: Request, db: Session = Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return _redirect_login()
|
||||
|
||||
logs = db.query(RebootLog).order_by(RebootLog.timestamp.desc()).all()
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["Timestamp (UTC)", "Username", "Email", "AP Name", "MAC", "IP", "Result", "Error"])
|
||||
for log in logs:
|
||||
writer.writerow([
|
||||
log.timestamp.isoformat(timespec="seconds"),
|
||||
log.username,
|
||||
log.user_email,
|
||||
log.ap_name,
|
||||
log.ap_mac,
|
||||
log.ap_ip or "",
|
||||
log.result,
|
||||
log.error_message or "",
|
||||
])
|
||||
|
||||
filename = f"audit_log_{datetime.date.today().isoformat()}.csv"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(buf.getvalue().encode("utf-8-sig")),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post("/api/reboot")
|
||||
async def api_reboot(request: Request, db: Session = Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
body = await request.json()
|
||||
_verify_csrf(request, body.get("csrf_token", ""))
|
||||
|
||||
mac: str = body.get("mac", "").strip()
|
||||
ap_name: str = body.get("name", "")
|
||||
ap_ip: str = body.get("ip", "")
|
||||
site_key: str = body.get("site_key", "")
|
||||
|
||||
if not mac:
|
||||
raise HTTPException(status_code=400, detail="MAC address required")
|
||||
|
||||
result = "success"
|
||||
error_msg: Optional[str] = None
|
||||
try:
|
||||
await omada_client.reboot_ap(mac, site_key)
|
||||
logger.info("Reboot sent for AP %s (%s) by %s", ap_name, mac, user["username"])
|
||||
except Exception as exc:
|
||||
result = "error"
|
||||
error_msg = str(exc)
|
||||
logger.error("Reboot failed for AP %s (%s): %s", ap_name, mac, exc)
|
||||
|
||||
db.add(RebootLog(
|
||||
timestamp=datetime.datetime.utcnow(),
|
||||
username=user["username"],
|
||||
user_email=user["email"],
|
||||
ap_name=ap_name,
|
||||
ap_mac=mac,
|
||||
ap_ip=ap_ip,
|
||||
result=result,
|
||||
error_message=error_msg,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
if result == "error":
|
||||
raise HTTPException(status_code=502, detail=error_msg)
|
||||
return {"status": "ok", "mac": mac}
|
||||
|
||||
|
||||
@app.post("/api/reboot-bulk")
|
||||
async def api_reboot_bulk(request: Request, db: Session = Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
body = await request.json()
|
||||
_verify_csrf(request, body.get("csrf_token", ""))
|
||||
|
||||
aps: list = body.get("aps", [])
|
||||
if not aps:
|
||||
raise HTTPException(status_code=400, detail="No APs specified")
|
||||
|
||||
results = []
|
||||
for ap in aps:
|
||||
mac = ap.get("mac", "").strip()
|
||||
ap_name = ap.get("name", "")
|
||||
ap_ip = ap.get("ip", "")
|
||||
site_key = ap.get("site_key", "")
|
||||
result = "success"
|
||||
error_msg = None
|
||||
try:
|
||||
await omada_client.reboot_ap(mac, site_key)
|
||||
logger.info("Bulk reboot sent for AP %s (%s) by %s", ap_name, mac, user["username"])
|
||||
except Exception as exc:
|
||||
result = "error"
|
||||
error_msg = str(exc)
|
||||
logger.error("Bulk reboot failed for AP %s (%s): %s", ap_name, mac, exc)
|
||||
|
||||
db.add(RebootLog(
|
||||
timestamp=datetime.datetime.utcnow(),
|
||||
username=user["username"],
|
||||
user_email=user["email"],
|
||||
ap_name=ap_name,
|
||||
ap_mac=mac,
|
||||
ap_ip=ap_ip,
|
||||
result=result,
|
||||
error_message=error_msg,
|
||||
))
|
||||
results.append({"mac": mac, "name": ap_name, "result": result, "error": error_msg})
|
||||
|
||||
db.commit()
|
||||
return {"results": results}
|
||||
214
app/omada.py
Normal file
214
app/omada.py
Normal file
@@ -0,0 +1,214 @@
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OMADA_BASE_URL = os.getenv("OMADA_BASE_URL", "https://192.168.1.1:8043").rstrip("/")
|
||||
OMADA_USERNAME = os.getenv("OMADA_USERNAME", "")
|
||||
OMADA_PASSWORD = os.getenv("OMADA_PASSWORD", "")
|
||||
OMADA_VERIFY_SSL = os.getenv("OMADA_VERIFY_SSL", "true").strip().lower() != "false"
|
||||
OMADA_CLIENT_ID = os.getenv("OMADA_CLIENT_ID", "")
|
||||
OMADA_CLIENT_SECRET = os.getenv("OMADA_CLIENT_SECRET", "")
|
||||
|
||||
|
||||
class OmadaClient:
|
||||
def __init__(self):
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._omadac_id: Optional[str] = None
|
||||
self._token: Optional[str] = None
|
||||
# {site_name: site_key}
|
||||
self._sites: Optional[dict[str, str]] = None
|
||||
# OpenAPI OAuth2
|
||||
self._openapi_client: Optional[httpx.AsyncClient] = None
|
||||
self._openapi_token: Optional[str] = None
|
||||
self._openapi_token_expires: float = 0.0
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Session client — carries web-login cookies for the v2 API."""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(
|
||||
verify=OMADA_VERIFY_SSL,
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _get_openapi_client(self) -> httpx.AsyncClient:
|
||||
"""Cookie-free client for OpenAPI calls — session cookies must not bleed in."""
|
||||
if self._openapi_client is None or self._openapi_client.is_closed:
|
||||
self._openapi_client = httpx.AsyncClient(
|
||||
verify=OMADA_VERIFY_SSL,
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
cookies={},
|
||||
)
|
||||
return self._openapi_client
|
||||
|
||||
def _auth_headers(self) -> dict:
|
||||
return {"Csrf-Token": self._token} if self._token else {}
|
||||
|
||||
async def _fetch_omadac_id(self) -> str:
|
||||
resp = await self._get_client().get(f"{OMADA_BASE_URL}/api/info")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("errorCode", -1) != 0:
|
||||
raise RuntimeError(f"Omada /api/info error: {data.get('msg')}")
|
||||
self._omadac_id = data["result"]["omadacId"]
|
||||
logger.info("Omada controller ID: %s", self._omadac_id)
|
||||
return self._omadac_id
|
||||
|
||||
async def _login(self) -> str:
|
||||
if not self._omadac_id:
|
||||
await self._fetch_omadac_id()
|
||||
resp = await self._get_client().post(
|
||||
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/login",
|
||||
json={"username": OMADA_USERNAME, "password": OMADA_PASSWORD},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("errorCode", -1) != 0:
|
||||
raise RuntimeError(f"Omada login failed: {data.get('msg')}")
|
||||
self._token = data["result"]["token"]
|
||||
logger.info("Omada web-API login successful")
|
||||
return self._token
|
||||
|
||||
async def _fetch_sites(self) -> dict[str, str]:
|
||||
"""Return {site_name: site_key} for all sites accessible to this user."""
|
||||
resp = await self._get_client().get(
|
||||
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/users/current",
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("errorCode", -1) != 0:
|
||||
raise RuntimeError(f"Omada users/current error: {data.get('msg')}")
|
||||
raw = data.get("result", {}).get("privilege", {}).get("sites", [])
|
||||
self._sites = {s["name"]: s["key"] for s in raw}
|
||||
logger.info("Loaded %d Omada sites", len(self._sites))
|
||||
return self._sites
|
||||
|
||||
async def _ensure_ready(self):
|
||||
if not self._token:
|
||||
await self._login()
|
||||
if self._sites is None:
|
||||
await self._fetch_sites()
|
||||
|
||||
async def _request_with_retry(self, method: str, url: str, **kwargs):
|
||||
client = self._get_client()
|
||||
resp = await client.request(method, url, headers=self._auth_headers(), **kwargs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("errorCode", 0) in (-1006, -1003, -30109):
|
||||
self._token = None
|
||||
self._sites = None
|
||||
await self._login()
|
||||
resp = await client.request(method, url, headers=self._auth_headers(), **kwargs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("errorCode", 0) != 0:
|
||||
raise RuntimeError(f"Omada API error {data.get('errorCode')}: {data.get('msg')}")
|
||||
return data
|
||||
|
||||
# ── OpenAPI OAuth2 (commands) ──────────────────────────────────────────────
|
||||
|
||||
async def _get_openapi_token(self) -> str:
|
||||
if self._openapi_token and time.time() < self._openapi_token_expires - 60:
|
||||
return self._openapi_token
|
||||
if not self._omadac_id:
|
||||
await self._fetch_omadac_id()
|
||||
resp = await self._get_openapi_client().post(
|
||||
f"{OMADA_BASE_URL}/openapi/authorize/token",
|
||||
data={
|
||||
"grantType": "client_credentials",
|
||||
"clientId": OMADA_CLIENT_ID,
|
||||
"clientSecret": OMADA_CLIENT_SECRET,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("errorCode", -1) != 0:
|
||||
raise RuntimeError(f"Omada OpenAPI token error: {data.get('msg')}")
|
||||
self._openapi_token = data["result"]["accessToken"]
|
||||
self._openapi_token_expires = time.time() + data["result"].get("expiresIn", 3600)
|
||||
logger.info("Omada OpenAPI token acquired")
|
||||
return self._openapi_token
|
||||
|
||||
async def _openapi_post(self, path: str, body: dict) -> dict:
|
||||
token = await self._get_openapi_token()
|
||||
resp = await self._get_openapi_client().post(
|
||||
f"{OMADA_BASE_URL}/openapi/v1/{self._omadac_id}/{path}",
|
||||
headers={"Authorization": f"AccessToken={token}"},
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("errorCode", 0) != 0:
|
||||
raise RuntimeError(f"Omada OpenAPI error {data.get('errorCode')}: {data.get('msg')}")
|
||||
return data
|
||||
|
||||
# ── Public methods ─────────────────────────────────────────────────────────
|
||||
|
||||
async def get_all_sites(self) -> dict[str, str]:
|
||||
await self._ensure_ready()
|
||||
return self._sites
|
||||
|
||||
async def get_aps(self) -> list[dict]:
|
||||
"""Fetch APs from every accessible site and return a combined list."""
|
||||
await self._ensure_ready()
|
||||
all_aps = []
|
||||
for site_name, site_key in self._sites.items():
|
||||
try:
|
||||
data = await self._request_with_retry(
|
||||
"GET",
|
||||
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/devices",
|
||||
)
|
||||
devices = data.get("result") or []
|
||||
for d in devices:
|
||||
if d.get("type") == "ap":
|
||||
d["_site_name"] = site_name
|
||||
d["_site_key"] = site_key
|
||||
all_aps.append(d)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch devices for site '%s': %s", site_name, exc)
|
||||
logger.info("Fetched %d APs across %d sites", len(all_aps), len(self._sites))
|
||||
return all_aps
|
||||
|
||||
async def _get_ap_uptime(self, mac: str, site_key: str) -> int:
|
||||
"""Return current uptimeLong (seconds) for a specific AP."""
|
||||
data = await self._request_with_retry(
|
||||
"GET",
|
||||
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/devices",
|
||||
)
|
||||
for device in (data.get("result") or []):
|
||||
if device.get("mac") == mac and device.get("type") == "ap":
|
||||
return int(device.get("uptimeLong", 0))
|
||||
raise RuntimeError(f"AP {mac} not found in site {site_key}")
|
||||
|
||||
async def reboot_ap(self, mac: str, site_key: str, min_uptime: int = 300) -> dict:
|
||||
await self._ensure_ready()
|
||||
uptime = await self._get_ap_uptime(mac, site_key)
|
||||
if uptime < min_uptime:
|
||||
mins = uptime // 60
|
||||
secs = uptime % 60
|
||||
raise RuntimeError(
|
||||
f"AP has only been online for {mins}m {secs}s. "
|
||||
f"A minimum uptime of {min_uptime // 60} minutes is required before rebooting."
|
||||
)
|
||||
resp = await self._get_client().post(
|
||||
f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/cmd/devices/{mac}/reboot",
|
||||
headers=self._auth_headers(),
|
||||
json={},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# -39009 = "Rebooting... Please wait." — Omada's success code for this command
|
||||
if data.get("errorCode", 0) not in (0, -39009):
|
||||
raise RuntimeError(f"Omada API error {data.get('errorCode')}: {data.get('msg')}")
|
||||
return data
|
||||
|
||||
|
||||
omada_client = OmadaClient()
|
||||
0
app/static/.gitkeep
Normal file
0
app/static/.gitkeep
Normal file
132
app/templates/audit.html
Normal file
132
app/templates/audit.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Audit Log – Salus by Stranto{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Audit Log</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">All reboot actions · {{ logs|length }} record{{ 's' if logs|length != 1 }}</p>
|
||||
</div>
|
||||
<a href="/audit/export"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-500 border border-blue-500 text-white transition-colors self-start sm:self-auto">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="get" class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="relative flex-1">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
<input type="text" name="username" value="{{ filter_username }}"
|
||||
placeholder="Filter by username…"
|
||||
class="w-full pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div class="relative flex-1">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0"/>
|
||||
</svg>
|
||||
<input type="text" name="ap_name" value="{{ filter_ap }}"
|
||||
placeholder="Filter by AP name…"
|
||||
class="w-full pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 transition-colors">
|
||||
Filter
|
||||
</button>
|
||||
{% if filter_username or filter_ap %}
|
||||
<a href="/audit"
|
||||
class="px-4 py-2 text-sm rounded-lg bg-gray-50 hover:bg-gray-100 border border-gray-200 text-gray-500 transition-colors">
|
||||
Clear
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if logs %}
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Timestamp (UTC)</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">User</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">AP Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">MAC</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Result</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{% for log in logs %}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 text-gray-500 font-mono text-xs whitespace-nowrap">
|
||||
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-gray-900 text-sm font-medium">{{ log.username }}</div>
|
||||
<div class="text-gray-400 text-xs">{{ log.user_email }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-900 font-medium">{{ log.ap_name }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ log.ap_mac }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ log.ap_ip or '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if log.result == 'success' %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
Success
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700 border border-red-200">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
Error
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-400 text-xs max-w-xs truncate" title="{{ log.error_message or '' }}">
|
||||
{{ log.error_message or '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<svg class="w-12 h-12 mb-4 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium">No log entries found</p>
|
||||
{% if filter_username or filter_ap %}
|
||||
<p class="text-sm mt-1">Try adjusting your filters.</p>
|
||||
{% else %}
|
||||
<p class="text-sm mt-1">Reboot actions will appear here.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
103
app/templates/base.html
Normal file
103
app/templates/base.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-white">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}Salus by Stranto{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: { 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
.toast { animation: slide-in 0.3s ease-out; }
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full text-gray-900 flex flex-col">
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center gap-8">
|
||||
<a href="/" class="flex items-center gap-2 font-bold text-lg text-gray-900 hover:text-blue-600 transition-colors">
|
||||
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v6m-3-3h6m6 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Salus <span class="font-normal text-gray-400 text-sm">by Stranto</span>
|
||||
</a>
|
||||
<div class="hidden sm:flex gap-1">
|
||||
<a href="/"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium transition-colors
|
||||
{% if request.url.path == '/' %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}">
|
||||
Access Points
|
||||
</a>
|
||||
<a href="/audit"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium transition-colors
|
||||
{% if request.url.path == '/audit' %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}">
|
||||
Audit Log
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if user %}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="hidden sm:block text-sm text-gray-500">
|
||||
<span class="text-gray-900 font-medium">{{ user.username }}</span>
|
||||
</span>
|
||||
<a href="/auth/logout"
|
||||
class="px-3 py-1.5 text-sm rounded-md bg-gray-100 text-gray-600 hover:bg-red-50 hover:text-red-700 border border-gray-200 transition-colors">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page content -->
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-gray-200 bg-white mt-auto">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between text-xs text-gray-400">
|
||||
<span>Version 1.6.1.0</span>
|
||||
<a href="https://www.stranto.com/" target="_blank" rel="noopener noreferrer"
|
||||
class="hover:text-blue-500 transition-colors">stranto.com</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div id="toast-container" class="fixed bottom-4 right-4 flex flex-col gap-2 z-50 pointer-events-none"></div>
|
||||
|
||||
<script>
|
||||
function showToast(msg, type = 'success') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const colors = type === 'success'
|
||||
? 'bg-green-50 border-green-200 text-green-800'
|
||||
: 'bg-red-50 border-red-200 text-red-800';
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast pointer-events-auto px-4 py-3 rounded-lg border shadow text-sm max-w-xs ${colors}`;
|
||||
el.textContent = msg;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => { el.remove(); }, 4000);
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
321
app/templates/index.html
Normal file
321
app/templates/index.html
Normal file
@@ -0,0 +1,321 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Access Points – Salus by Stranto{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Header row -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Access Points</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Live from Omada Controller · <span id="visible-count">{{ aps|length }}</span> of {{ aps|length }} device{{ 's' if aps|length != 1 }} across all sites</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
||||
</svg>
|
||||
<input id="filter-name" type="text" placeholder="Filter by name or site…"
|
||||
class="pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-52" />
|
||||
</div>
|
||||
<button id="btn-refresh"
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-600 transition-colors">
|
||||
<svg id="refresh-icon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
<button id="btn-reboot-selected" disabled
|
||||
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 disabled:opacity-40 disabled:cursor-not-allowed border border-red-500 text-white transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Reboot selected (<span id="sel-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="flex items-start gap-3 p-4 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
<svg class="w-5 h-5 flex-shrink-0 mt-0.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Failed to connect to Omada Controller</p>
|
||||
<p class="mt-0.5 text-red-600">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if aps %}
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left w-10">
|
||||
<input type="checkbox" id="chk-all"
|
||||
class="rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer" />
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Site</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP Address</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">MAC Address</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Model</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Status</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Uptime</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-500 uppercase tracking-wide text-xs">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white" id="ap-table-body">
|
||||
{% for ap in aps %}
|
||||
{% set online = ap.get('statusCategory', 0) == 1 %}
|
||||
{% set mac = ap.get('mac', '') %}
|
||||
{% set name = ap.get('name', 'Unknown') %}
|
||||
{% set ip = ap.get('ip', '') %}
|
||||
{% set site_name = ap.get('_site_name', '') %}
|
||||
{% set site_key = ap.get('_site_key', '') %}
|
||||
{% set uptime_secs = ap.get('uptimeLong', 0) | int %}
|
||||
{% set reboot_allowed = online and uptime_secs >= 300 %}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<input type="checkbox" class="ap-checkbox rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer
|
||||
{% if not reboot_allowed %}opacity-40 cursor-not-allowed{% endif %}"
|
||||
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
|
||||
{% if not reboot_allowed %}disabled
|
||||
title="{% if not online %}AP is offline{% else %}Uptime too low — minimum 5 minutes required{% endif %}"
|
||||
{% endif %} />
|
||||
</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{{ name }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">{{ site_name }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ ip or '—' }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ mac or '—' }}</td>
|
||||
<td class="px-4 py-3 text-gray-500">{{ ap.get('model', '—') }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if online %}
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
Online
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 border border-gray-200">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||
Offline
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">
|
||||
{% set up = ap.get('uptimeLong', 0) | int %}
|
||||
{% if up %}
|
||||
{{ up // 86400 }}d {{ (up % 86400) // 3600 }}h {{ (up % 3600) // 60 }}m
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
class="btn-reboot px-3 py-1.5 text-xs rounded-lg font-medium transition-colors
|
||||
{% if reboot_allowed %}bg-red-600 hover:bg-red-500 border border-red-500 text-white cursor-pointer
|
||||
{% else %}bg-gray-100 border border-gray-200 text-gray-400 cursor-not-allowed opacity-50{% endif %}"
|
||||
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
|
||||
{% if not reboot_allowed %}disabled
|
||||
title="{% if not online %}AP is offline{% else %}Uptime too low ({{ uptime_secs // 60 }}m {{ uptime_secs % 60 }}s) — minimum 5 minutes required{% endif %}"
|
||||
{% endif %}>
|
||||
Reboot
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% elif not error %}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<svg class="w-12 h-12 mb-4 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium">No access points found</p>
|
||||
<p class="text-sm mt-1">Check your Omada site name configuration.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" id="modal-backdrop"></div>
|
||||
<div class="relative bg-white border border-gray-200 rounded-2xl shadow-xl w-full max-w-md p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Confirm Reboot</h2>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm mb-6" id="modal-message">Are you sure?</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button id="modal-cancel"
|
||||
class="px-4 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="modal-confirm"
|
||||
class="px-4 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 border border-red-500 text-white font-semibold transition-colors">
|
||||
Reboot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const CSRF_TOKEN = {{ csrf_token | tojson }};
|
||||
|
||||
// ── Name / site filter ────────────────────────────────────────────────────
|
||||
const filterInput = document.getElementById('filter-name');
|
||||
const visibleCount = document.getElementById('visible-count');
|
||||
const allRows = [...document.querySelectorAll('#ap-table-body tr')];
|
||||
|
||||
filterInput.addEventListener('input', () => {
|
||||
const q = filterInput.value.toLowerCase().trim();
|
||||
let shown = 0;
|
||||
allRows.forEach(row => {
|
||||
const name = (row.querySelector('td:nth-child(2)')?.textContent || '').toLowerCase();
|
||||
const site = (row.querySelector('td:nth-child(3)')?.textContent || '').toLowerCase();
|
||||
const match = !q || name.includes(q) || site.includes(q);
|
||||
row.style.display = match ? '' : 'none';
|
||||
if (match) shown++;
|
||||
});
|
||||
visibleCount.textContent = shown;
|
||||
updateBulkButton();
|
||||
});
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-refresh').addEventListener('click', () => {
|
||||
const icon = document.getElementById('refresh-icon');
|
||||
icon.classList.add('animate-spin');
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// ── Checkbox logic ─────────────────────────────────────────────────────────
|
||||
const chkAll = document.getElementById('chk-all');
|
||||
const btnRebootSel = document.getElementById('btn-reboot-selected');
|
||||
const selCount = document.getElementById('sel-count');
|
||||
|
||||
function updateBulkButton() {
|
||||
const checked = [...document.querySelectorAll('.ap-checkbox:checked')]
|
||||
.filter(cb => cb.closest('tr').style.display !== 'none');
|
||||
selCount.textContent = checked.length;
|
||||
btnRebootSel.disabled = checked.length === 0;
|
||||
}
|
||||
|
||||
chkAll?.addEventListener('change', () => {
|
||||
document.querySelectorAll('.ap-checkbox:not(:disabled)').forEach(cb => {
|
||||
cb.checked = chkAll.checked;
|
||||
});
|
||||
updateBulkButton();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.ap-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', updateBulkButton);
|
||||
});
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────────────────────
|
||||
const modal = document.getElementById('modal');
|
||||
const modalMsg = document.getElementById('modal-message');
|
||||
const modalConfirm = document.getElementById('modal-confirm');
|
||||
|
||||
let pendingAction = null;
|
||||
|
||||
function showModal(message, onConfirm) {
|
||||
modalMsg.textContent = message;
|
||||
pendingAction = onConfirm;
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
modal.classList.add('hidden');
|
||||
pendingAction = null;
|
||||
}
|
||||
|
||||
document.getElementById('modal-backdrop').addEventListener('click', hideModal);
|
||||
document.getElementById('modal-cancel').addEventListener('click', hideModal);
|
||||
modalConfirm.addEventListener('click', () => {
|
||||
if (pendingAction) pendingAction();
|
||||
hideModal();
|
||||
});
|
||||
|
||||
// ── Single reboot ──────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.btn-reboot').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mac = btn.dataset.mac;
|
||||
const name = btn.dataset.name;
|
||||
const ip = btn.dataset.ip;
|
||||
const site_key = btn.dataset.siteKey;
|
||||
showModal(`Reboot AP "${name}"?\nThis will disconnect all clients connected to this AP.`, async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Rebooting…';
|
||||
try {
|
||||
const res = await fetch('/api/reboot', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mac, name, ip, site_key, csrf_token: CSRF_TOKEN }),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(`Reboot command sent to "${name}"`, 'success');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
showToast(`Error: ${err.detail || 'Unknown error'}`, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Reboot';
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Network error', 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Reboot';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bulk reboot ────────────────────────────────────────────────────────────
|
||||
btnRebootSel?.addEventListener('click', () => {
|
||||
const checked = [...document.querySelectorAll('.ap-checkbox:checked')];
|
||||
const aps = checked.map(cb => ({ mac: cb.dataset.mac, name: cb.dataset.name, ip: cb.dataset.ip, site_key: cb.dataset.siteKey }));
|
||||
const names = aps.map(a => a.name).join(', ');
|
||||
showModal(`Reboot ${aps.length} AP${aps.length > 1 ? 's' : ''}?\n${names}`, async () => {
|
||||
btnRebootSel.disabled = true;
|
||||
btnRebootSel.querySelector('span').textContent = '0';
|
||||
try {
|
||||
const res = await fetch('/api/reboot-bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aps, csrf_token: CSRF_TOKEN }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const errors = (data.results || []).filter(r => r.result === 'error');
|
||||
if (errors.length === 0) {
|
||||
showToast(`Reboot sent to ${aps.length} AP${aps.length > 1 ? 's' : ''}`, 'success');
|
||||
} else {
|
||||
showToast(`${errors.length} AP(s) failed to reboot`, 'error');
|
||||
}
|
||||
document.querySelectorAll('.ap-checkbox').forEach(cb => { cb.checked = false; });
|
||||
if (chkAll) chkAll.checked = false;
|
||||
updateBulkButton();
|
||||
} catch (e) {
|
||||
showToast('Network error', 'error');
|
||||
updateBulkButton();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
38
app/templates/login.html
Normal file
38
app/templates/login.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Login – Salus by Stranto</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="h-full flex items-center justify-center text-gray-900">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="bg-white border border-gray-200 rounded-2xl shadow-lg p-8 flex flex-col items-center gap-6">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="w-14 h-14 rounded-full bg-blue-600 flex items-center justify-center shadow">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v6m-3-3h6m6 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-gray-900">Salus <span class="font-normal text-gray-400 text-base">by Stranto</span></h1>
|
||||
<p class="text-sm text-gray-500">Sign in to manage your access points</p>
|
||||
</div>
|
||||
|
||||
<a href="/auth/login/start"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold transition-colors shadow">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 3H19a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3"/>
|
||||
</svg>
|
||||
Sign in with Authentik
|
||||
</a>
|
||||
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
Access is restricted to authorized users only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user