diff --git a/README.md b/README.md index a721d42..db42859 100644 --- a/README.md +++ b/README.md @@ -647,7 +647,7 @@ Three Dashboard visuals are now NOC widgets: **Airtime by Band** (average channe ### Widget endpoints now require the suite token -`app/api/widgets.py` previously mounted its router with a bare `APIRouter()`, +`app/api/widgets/` previously mounted its router with a bare `APIRouter()`, so the server-rendered widget views — which read internal data — answered anyone who could reach the port. The router now carries `dependencies=[Depends(require_suite_token)]`, matching the NOC Builder's @@ -878,7 +878,7 @@ Point resonance's **READ SPEC** at `/api/resonance/openapi.json`. The published Every call is made by pktWiFi's own page, same-origin, on the session of the person already signed in, so nothing here reaches data that person could not already open. Which operations exist is -fixed in `app/api/resonance_data.py`, not configurable per install. Write operations are withheld +fixed in `app/api/resonance_data/`, not configurable per install. Write operations are withheld from the grant entirely until an administrator sets a role to **Read and write**. **Never exposed:** a collector's stored controller credentials. Nothing here changes a channel or transmit power, deauthenticates a client, or creates, edits or deletes an access point, SSID, radio or collector. The app has no rule-toggle endpoint, so the assistant's writes are acknowledge-only. diff --git a/app/api/collectors.py b/app/api/collectors.py index 0c24d61..0d0579b 100644 --- a/app/api/collectors.py +++ b/app/api/collectors.py @@ -5,6 +5,7 @@ from __future__ import annotations import json +import logging import aiosqlite from fastapi import APIRouter, Depends, HTTPException @@ -15,6 +16,8 @@ from app.wifi.collectors.registry import COLLECTOR_TYPES from app.wifi.collectors.crypto import encrypt_config, decrypt_config +log = logging.getLogger("pktwifi.api.collectors") + router = APIRouter() @@ -37,6 +40,7 @@ def _collector_out(r, reveal_config: bool = False) -> dict: try: out["config"] = decrypt_config(r["config_json"]) except Exception: + log.warning("collector %s: stored config could not be read", r["id"], exc_info=True) out["config"] = {} return out diff --git a/app/api/credentials.py b/app/api/credentials.py index 5dcdf71..ce19c94 100644 --- a/app/api/credentials.py +++ b/app/api/credentials.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from app.database import get_db +from app.errors import describe_exception from app.dependencies import CurrentUser, AdminUser from app.wifi.collectors.crypto import encrypt_str, decrypt_config @@ -140,7 +141,7 @@ class CredentialTestRequest(BaseModel): def _exc_detail(exc: Exception) -> str: # Same convention as poll_now: some httpx exceptions have an empty str(). - return f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ + return describe_exception(exc, with_type=True) @router.post("/{cred_id}/test") diff --git a/app/api/logs.py b/app/api/logs.py index 03df26a..5571d76 100644 --- a/app/api/logs.py +++ b/app/api/logs.py @@ -21,6 +21,7 @@ from app.database import get_db from app.dependencies import AdminUser, CurrentUser +from app.sqlutil import Where from app.wifi.collectors.crypto import decrypt_str router = APIRouter() @@ -48,34 +49,28 @@ async def get_logs( limit: int = Query(200, ge=1, le=1000), offset: int = Query(0, ge=0), ): - conditions: list[str] = [] - params: list = [] + w = Where() if level: level_no = _LEVEL_MAP.get(level.upper()) if level_no is not None: - conditions.append("level_no >= ?") - params.append(level_no) + w.add("level_no >= ?", level_no) if logger: - conditions.append("logger LIKE ?") - params.append(f"{logger}%") + w.add("logger LIKE ?", f"{logger}%") if search: - conditions.append("message LIKE ?") - params.append(f"%{search}%") + w.add("message LIKE ?", f"%{search}%") if since: - conditions.append("created_at >= ?") - params.append(since) + w.add("created_at >= ?", since) if until: - conditions.append("created_at <= ?") - params.append(until) + w.add("created_at <= ?", until) - where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + where = w.sql - async with db.execute(f"SELECT COUNT(*) FROM app_logs {where}", params) as cur: + async with db.execute(f"SELECT COUNT(*) FROM app_logs {where}", w.params) as cur: total = (await cur.fetchone())[0] async with db.execute( @@ -86,7 +81,7 @@ async def get_logs( ORDER BY id DESC LIMIT ? OFFSET ? """, - params + [limit, offset], + w.with_params(limit, offset), ) as cur: rows = await cur.fetchall() diff --git a/app/api/nav.py b/app/api/nav.py index 0a6afdc..60befbc 100644 --- a/app/api/nav.py +++ b/app/api/nav.py @@ -19,7 +19,7 @@ # Fetched unauthenticated by pktHub's health poller, and it discloses this # app's page structure — so it carries the same X-Suite-Token gate as the -# widget endpoints in app/api/widgets.py. +# widget endpoints in app/api/widgets/. router = APIRouter(dependencies=[Depends(require_suite_token)]) # ── Manifest ────────────────────────────────────────────────────────────────── diff --git a/app/api/resonance_data.py b/app/api/resonance_data.py deleted file mode 100644 index 6cbaeca..0000000 --- a/app/api/resonance_data.py +++ /dev/null @@ -1,1384 +0,0 @@ -""" -app/api/resonance_data.py — the data half of the resonance contract. - -app/api/resonance.py mounts the panel. This module is what the panel is -allowed to *read* once it is mounted, and it exists because the embed contract -has three parts and mounting only satisfies one of them: - - 1. an OpenAPI document at a stable same-origin path -> /api/resonance/openapi.json - 2. a grant file naming what may be called -> /.well-known/resonance.json - 3. endpoints that behave: bounded, JSON, stable fields -> /api/resonance/data/* - -Why a separate surface rather than granting against /api/access-points/* and friends. -The operations named in a grant have to carry a stable operationId, prose a -stranger can choose between, enums for every fixed vocabulary, a declared -response schema, and a bounded page with a total. pktWiFi's own endpoints were -written for a SPA that already knows all of that: most return a bare array with -no total and no paging. Retrofitting the contract onto them would change -response shapes the frontend already consumes. These wrap the same tables -instead, so there is no second implementation of any query — only a second, -narrower doorway with the labels the model needs. - -Authentication is the app's existing session, not a new one. The panel's calls -are ordinary same-origin fetches from our own page, so they carry the refresh -cookie exactly as /api/resonance/code does, and they are admitted by the same -helpers that admit /code — see resonance_session_user below. Nothing here -issues, accepts or understands a credential of resonance's, and the panel can -therefore only ever read what the signed-in person could already read. - -WHAT IS DELIBERATELY ABSENT IS PART OF THE DESIGN. A collector's stored -configuration — the controller credentials it polls with — is never selected, so -it cannot reach the assistant through a schema's `extra` either. Nothing here -creates, edits or deletes an access point, an SSID, a radio or a collector, -nothing changes a channel or a transmit power, and nothing deauthenticates a -client. Acknowledging an alert is the only thing that changes state, and -pktWiFi's own interface has no rule on/off switch for an assistant to mirror. -""" -from __future__ import annotations - -import asyncio -import copy -import json -import logging -from dataclasses import dataclass -from typing import Any, Literal, Optional - -import aiosqlite -from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request -from fastapi.exceptions import ResponseValidationError -from fastapi.responses import JSONResponse -from pydantic import BaseModel, ConfigDict, Field - -from app.database import get_db -from app.wifi.freshness import client_is_current, radio_is_current - -# Deliberately the same helpers /api/resonance/code uses, imported rather than -# reimplemented: the two surfaces must never disagree about who counts as -# signed in, which origin counts as ours, or whether the feature is on. -from app.api.resonance import ( - LEVEL_RANK, _allowed_roles, _get, _same_origin, _user_for_code, role_level, -) -from app.dependencies import require_admin, require_analyst - -log = logging.getLogger("pktwifi.api.resonance_data") - -router = APIRouter(tags=["resonance-data"]) - -DATA_PREFIX = "/api/resonance/data" -SPEC_PATH = "/api/resonance/openapi.json" -GRANT_PATH = "/.well-known/resonance.json" - - -# ── What the assistant is allowed to call ──────────────────────────────────── -# -# The one list. The grant file is generated from it, the published spec is -# filtered to it, and startup checks it against the routes that actually exist. -# An operationId that is not here is invisible to the assistant even though it -# is a perfectly ordinary route of this app. - - -@dataclass(frozen=True) -class Grant: - op: str - # Set on ANY operation that changes state, whatever its HTTP verb. - # Resonance reads the values back to the person before running one. - writes: bool = False - - -GRANTED: tuple[Grant, ...] = ( - Grant("getWifiSummary"), - Grant("listAccessPoints"), - Grant("getAccessPoint"), - Grant("listWifiClients"), - Grant("listRadios"), - Grant("listCollectors"), - Grant("listAlertEvents"), - Grant("listAlertRules"), - Grant("searchApplicationLog"), - # The only state change on offer. There is deliberately no channel or power - # change, no client deauthentication, and no create, edit or delete of - # anything: an assistant may acknowledge what an administrator is already - # being told about, and nothing else. - Grant("ackAlertEvent", writes=True), - Grant("ackAllAlertEvents", writes=True), -) - - -# ── Vocabulary ──────────────────────────────────────────────────────────────── -# -# These are the enums the requirement is really about: without them a model asks -# for a band of "5G" or a status of "offline", gets a 422, and reports the app -# as broken. All of these are fixed in pktWiFi's own code — the -# install-specific vocabulary (AP names, SSIDs, site names) cannot be, and is -# published through listAccessPoints and listWifiClients instead. - -ApStatus = Literal["online", "offline", "unknown"] -Band = Literal["2.4", "5", "6"] -AlertSeverity = Literal["info", "warning", "critical"] -LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - - -# ── Errors ──────────────────────────────────────────────────────────────────── - - -class ResonanceDataError(HTTPException): - """Rendered as {"error": "..."} — the message reaches the person verbatim.""" - - -class ErrorResponse(BaseModel): - error: str = Field(description="What went wrong, phrased for the person to act on.") - - -def register_error_handler(app) -> None: - """Give this surface the {"error": ...} body the grant contract specifies. - - Scoped to ResonanceDataError so the rest of the app keeps FastAPI's - {"detail": ...}, which its own frontend already reads. - """ - - @app.exception_handler(ResonanceDataError) - async def _render(_request: Request, exc: ResonanceDataError): # noqa: ANN202 - return JSONResponse({"error": exc.detail}, status_code=exc.status_code) - - @app.exception_handler(ResponseValidationError) - async def _schema_drifted(request: Request, exc: ResponseValidationError): # noqa: ANN202 - """Report a declared schema that no longer matches what the tables return. - - This fires after the route body has already succeeded, so the module's - own try/except cannot see it, and it is logged by uvicorn rather than by - anything the SQLite handler is attached to — a 500 with a generic - message in the panel and not one line anywhere on the server. Now it - names the fields. - - Only this surface is rewritten; every other response_model in the app - keeps FastAPI's existing behaviour. - """ - if not request.url.path.startswith("/api/resonance/"): - raise exc - fields = sorted({".".join(str(p) for p in err.get("loc", ())[-2:]) - for err in exc.errors()})[:8] - log.error( - "resonance response schema no longer matches the data on %s: %s", - request.url.path, ", ".join(fields) or "unknown field", - ) - return JSONResponse( - {"error": "pktWiFi produced a result it could not describe. This is a fault in " - "pktWiFi, not in the question — it has been logged."}, - status_code=500, - ) - - -_ERRORS: dict[int | str, dict[str, Any]] = { - 401: {"model": ErrorResponse, "description": "No signed-in session on this request."}, - 403: {"model": ErrorResponse, "description": "Signed in, but not permitted to use the assistant."}, - 404: {"model": ErrorResponse, "description": "The assistant is switched off on this install."}, - 503: {"model": ErrorResponse, "description": "A backing store this operation needs is not available."}, - 504: {"model": ErrorResponse, "description": "The store did not answer in time; ask something narrower."}, -} - - -# ── Session ─────────────────────────────────────────────────────────────────── - - -async def resonance_session_user( - request: Request, db: aiosqlite.Connection = Depends(get_db) -) -> dict: - """Admit a call the panel made from our own page, on this app's own session. - - Same four gates as /api/resonance/code, in the same order and for the same - reasons: the request must present as same-origin before any cookie is - honoured, it must carry a session we recognise, the feature must be on, and - the person's role must be one an admin listed. The last two mean this whole - surface is inert on an install that never enabled the panel — a route that - exists but answers 404 until someone turns the feature on deliberately. - """ - if not _same_origin(request): - raise ResonanceDataError(status_code=403, detail="Cross-site request refused.") - - user = await _user_for_code(request, db) - if not user: - raise ResonanceDataError(status_code=401, detail="Not signed in to pktWiFi.") - - if not bool(await _get(db, "resonance_enabled", False)): - raise ResonanceDataError(status_code=404, detail="The assistant is not enabled on this install.") - - if user["role"] not in await _allowed_roles(db): - raise ResonanceDataError( - status_code=403, detail="Your role is not permitted to use the assistant." - ) - - # Audit trail, and the only way to answer "did the assistant actually ask us - # anything". A successful read is otherwise silent, so without this the - # difference between "the panel never called" and "the panel called and got - # what it wanted" is invisible from the server — which is exactly the - # question asked when an answer looks wrong. One line per call, at INFO, so - # it lands in the Logs page too. - route = request.scope.get("route") - log.info( - "resonance call: %s (%s) -> %s", - user.get("username"), user.get("role"), - getattr(route, "operation_id", None) or request.url.path, - ) - return user - - -async def resonance_write_user( - request: Request, db: aiosqlite.Connection = Depends(get_db) -) -> dict: - """As above, and the role must be set to "write" rather than "read". - - Two gates have to agree before anything changes, and they answer different - questions. This one is the admin's: has this role been trusted to let the - assistant act at all. The second, inside each operation, is pktWiFi's own: - may this person do this thing anyway. A role set to "write" never gains a - right its holder does not already have in the interface — it only decides - whether the assistant may exercise the rights they do have. - """ - user = await resonance_session_user(request, db) - if LEVEL_RANK.get(await role_level(db, user["role"]), 0) < LEVEL_RANK["write"]: - raise ResonanceDataError( - status_code=403, - detail=("The assistant is set to read-only for your role, so it cannot make " - "that change. An administrator sets this under Settings → Resonance."), - ) - return user - - -async def _apply_app_rule(user: dict, rule, what: str) -> None: - """Apply pktWiFi's own role rule for the endpoint this operation mirrors. - - The rule itself is imported rather than restated, so a change to who may do - something in the interface reaches the assistant in the same commit instead - of leaving two role models to drift apart. - """ - try: - await rule(user) - except HTTPException as exc: - raise ResonanceDataError( - status_code=exc.status_code, - detail=f"Your pktWiFi role does not permit you to {what}.", - ) from exc - - -SessionUser = Depends(resonance_session_user) -WriteUser = Depends(resonance_write_user) - - -class AccessPoint(BaseModel): - """One access point pktWiFi knows about.""" - - model_config = ConfigDict(extra="allow") - - id: int - name: Optional[str] = None - mac_address: Optional[str] = None - ip_address: Optional[str] = None - vendor: Optional[str] = None - model: Optional[str] = None - firmware_version: Optional[str] = None - site: Optional[str] = None - floor: Optional[str] = None - status: Optional[str] = Field(None, description="online, offline, or unknown.") - is_rogue: bool = Field(False, description="True when this AP is not one of ours.") - uptime_seconds: Optional[int] = None - client_count: int = Field(0, description="Clients currently associated across its radios.") - collector_id: Optional[int] = None - last_seen: Optional[str] = Field(None, description="When it was last polled (ISO 8601).") - - -class AccessPointList(BaseModel): - model_config = ConfigDict(extra="allow") - total: int = Field(description="How many access points matched, before paging.") - limit: int - offset: int - returned: int = 0 - truncated_for_size: bool = Field( - False, description="True when the page was cut to fit. Ask for fewer, or narrow the filters." - ) - access_points: list[AccessPoint] = Field(default_factory=list) - - -class WifiClient(BaseModel): - """One client currently associated, as last observed.""" - - model_config = ConfigDict(extra="allow") - - id: int - mac_address: Optional[str] = None - hostname: Optional[str] = None - ip_address: Optional[str] = None - ssid: Optional[str] = None - band: Optional[str] = Field(None, description="2.4, 5 or 6 (GHz).") - protocol: Optional[str] = Field(None, description="The 802.11 generation it associated with.") - rssi_dbm: Optional[int] = Field( - None, description="Signal strength in dBm. Closer to zero is stronger; below -70 is poor." - ) - snr_db: Optional[int] = Field(None, description="Signal-to-noise ratio in dB. Under 20 is poor.") - tx_rate_mbps: Optional[float] = None - rx_rate_mbps: Optional[float] = None - access_point_id: Optional[int] = None - access_point_name: Optional[str] = Field(None, description="Which AP it is on, for reading back.") - connected_at: Optional[str] = None - last_seen: Optional[str] = Field(None, description="When it was last observed (ISO 8601).") - - -class WifiClientList(BaseModel): - model_config = ConfigDict(extra="allow") - total: int - limit: int - offset: int - returned: int = 0 - truncated_for_size: bool = False - clients: list[WifiClient] = Field(default_factory=list) - - -class Radio(BaseModel): - """One radio on an access point — the thing a channel and a power belong to.""" - - model_config = ConfigDict(extra="allow") - - id: int - access_point_id: Optional[int] = None - access_point_name: Optional[str] = None - band: Optional[str] = Field(None, description="2.4, 5 or 6 (GHz).") - channel: Optional[int] = None - channel_width_mhz: Optional[int] = None - tx_power_dbm: Optional[int] = None - utilization_pct: Optional[float] = Field( - None, description="How busy the channel is. Sustained above 50 is congestion." - ) - noise_floor_dbm: Optional[int] = None - client_count: Optional[int] = None - updated_at: Optional[str] = None - - -class RadioList(BaseModel): - model_config = ConfigDict(extra="allow") - total: int - returned: int = 0 - truncated_for_size: bool = False - radios: list[Radio] = Field(default_factory=list) - - -class Collector(BaseModel): - """A collector pktWiFi polls a wireless controller through.""" - - model_config = ConfigDict(extra="allow") - - id: int - name: Optional[str] = None - collector_type: Optional[str] = Field(None, description="Which controller it speaks to.") - enabled: bool = False - status: Optional[str] = None - poll_interval_sec: Optional[int] = None - last_poll_at: Optional[str] = Field(None, description="When it last ran (ISO 8601).") - last_error: Optional[str] = Field(None, description="Why the last poll failed, if it did.") - - -class CollectorList(BaseModel): - model_config = ConfigDict(extra="allow") - total: int - returned: int = 0 - truncated_for_size: bool = False - collectors: list[Collector] = Field(default_factory=list) - - -class WifiSummary(BaseModel): - """Counts across the whole wireless estate — the "how are we doing" answer.""" - - model_config = ConfigDict(extra="allow") - - access_points: int - access_points_online: int - rogue_access_points: int = Field(description="APs seen that are not ours.") - clients: int = Field(description="Clients currently associated.") - radios: int - ssids: int - sites: int - collectors: int - collectors_enabled: int - unacknowledged_alerts: int - - -class AlertEvent(BaseModel): - """One firing of a pktWiFi alert rule.""" - - model_config = ConfigDict(extra="allow") - - id: int - rule_id: Optional[int] = None - rule_name: Optional[str] = Field(None, description="Name of the rule that fired.") - access_point_id: Optional[int] = Field(None, description="The AP it is about, if any.") - client_mac: Optional[str] = Field(None, description="The client it is about, if any.") - severity: Optional[str] = None - message: Optional[str] = None - value: Optional[float] = None - threshold: Optional[float] = None - active: bool = Field(False, description="True while the condition behind it still holds.") - acked: bool = False - acked_by: Optional[str] = None - acked_at: Optional[str] = None - resolved: bool = False - resolved_at: Optional[str] = None - created_at: Optional[str] = Field(None, description="When it fired (ISO 8601).") - - -class AlertEventList(BaseModel): - model_config = ConfigDict(extra="allow") - total: int - limit: int - offset: int - returned: int = 0 - truncated_for_size: bool = False - events: list[AlertEvent] = Field(default_factory=list) - - -class AlertRule(BaseModel): - model_config = ConfigDict(extra="allow") - id: int - name: Optional[str] = None - condition_type: Optional[str] = Field(None, description="What the rule watches.") - threshold: Optional[float] = None - severity: Optional[str] = None - enabled: bool = False - created_at: Optional[str] = None - channels: list[str] = Field( - default_factory=list, - description=( - "Which notification channels this rule sends on when it fires. Empty means it records " - "the firing here and notifies nobody — an enabled rule with no channels is being " - "watched but not reported." - ), - ) - - -class AlertRuleList(BaseModel): - model_config = ConfigDict(extra="allow") - total: int - returned: int = 0 - truncated_for_size: bool = False - rules: list[AlertRule] = Field(default_factory=list) - - -class AppLogRecord(BaseModel): - """One line of pktWiFi's own diagnostic log — not wireless data.""" - - model_config = ConfigDict(extra="allow") - - id: int - level: Optional[str] = None - logger: Optional[str] = Field(None, description="Which part of pktWiFi wrote it.") - message: Optional[str] = None - created_at: Optional[str] = Field(None, description="When it was written (ISO 8601).") - - -class AppLogResult(BaseModel): - model_config = ConfigDict(extra="allow") - total: int - limit: int - offset: int - returned: int = 0 - truncated_for_size: bool = False - records: list[AppLogRecord] = Field(default_factory=list) - - -# ── Operations ──────────────────────────────────────────────────────────────── -# -# Every summary and description here is written for a reader who has never seen -# pktWiFi, because that is literally what chooses between them: a model picks an -# operation from these sentences and nothing else. "Search logs" would leave it -# guessing between the certificate inventory and the app's own diagnostics, -# which are two entirely different questions asked with almost the same words. - -# One page is capped well below what the SPA allows. The panel's results are -# read back to a person in a conversation, so a hundred rows is already past the -# point of being an answer, and a model handed five hundred narrows nothing. The -# maxima are deliberately above what always fits — _fit() reports the cut, and a -# caller that wants density should be able to ask for it. -_SEARCH_DEFAULT, _SEARCH_MAX = 25, 100 -_LIST_DEFAULT, _LIST_MAX = 50, 200 - -# Resonance truncates a result over 20 KB and tells the model it did. That turns -# a clean page into JSON that stops mid-record, so the cut is made here instead, -# where it can leave the envelope intact and say what happened in a field the -# model can act on. 18 KB leaves headroom for transport framing. -_RESULT_BUDGET_BYTES = 18_000 - -# Resonance gives up on a call after 20 seconds and tells the person the -# application did not answer. Answering at 15 with something they can act on -# beats going quiet at 20. -_CALL_TIMEOUT_SECONDS = 15 - - -def _encoded_size(value: Any) -> int: - return len(json.dumps(value, default=str).encode("utf-8")) - - -def _fit(payload: dict, items_key: str) -> dict: - """Trim a page to the byte budget, and record that it had to. - - Always keeps at least one item: an empty page for one oversized record is a - worse answer than an oversized one, and the caller can still see `total`. - """ - items = list(payload.get(items_key) or []) - # Price the envelope with the two fields this adds, so adding them cannot - # push a result that just fitted back over the line. - envelope = dict(payload) - envelope[items_key] = [] - envelope["returned"] = len(items) - envelope["truncated_for_size"] = True - budget = _RESULT_BUDGET_BYTES - _encoded_size(envelope) - - kept: list = [] - used = 0 - for item in items: - size = _encoded_size(item) + 1 # + the separating comma - if kept and used + size > budget: - break - kept.append(item) - used += size - - payload[items_key] = kept - payload["returned"] = len(kept) - payload["truncated_for_size"] = len(kept) < len(items) - return payload - - -async def _in_time(awaitable, what: str): - """Bound a query so a slow one is answered rather than abandoned.""" - try: - return await asyncio.wait_for(awaitable, _CALL_TIMEOUT_SECONDS) - except asyncio.TimeoutError as exc: - raise ResonanceDataError( - status_code=504, - detail=( - f"pktWiFi took longer than {_CALL_TIMEOUT_SECONDS} seconds to {what}. " - "Narrow the time range, or filter by status, CA or name." - ), - ) from exc - -@router.get( - f"{DATA_PREFIX}/summary", - operation_id="getWifiSummary", - summary="Counts across the whole wireless estate", - description=( - "One small result answering 'how are we doing' — how many access points exist and how " - "many are online, how many rogues have been seen, how many clients are associated, how " - "many radios, SSIDs and sites are configured, how many collectors are enabled, and how " - "many alerts are outstanding. Ask this before listAccessPoints when the question is " - "about totals rather than about particular access points." - ), - response_model=WifiSummary, - responses=_ERRORS, -) -async def get_wifi_summary( - _user: dict = SessionUser, - db: aiosqlite.Connection = Depends(get_db), -): - async def _count(query: str) -> int: - async with db.execute(query) as cur: - row = await cur.fetchone() - return row[0] if row else 0 - - return { - "access_points": await _count("SELECT COUNT(*) FROM access_points"), - "access_points_online": await _count( - "SELECT COUNT(*) FROM access_points WHERE status = 'online'" - ), - "rogue_access_points": await _count( - "SELECT COUNT(*) FROM access_points WHERE is_rogue = 1" - ), - "clients": await _count( - f"SELECT COUNT(*) FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id " - f"WHERE {client_is_current()}" - ), - "radios": await _count( - f"SELECT COUNT(*) FROM radios r JOIN access_points ap ON ap.id = r.access_point_id " - f"WHERE {radio_is_current()}" - ), - "ssids": await _count("SELECT COUNT(*) FROM ssids"), - "sites": await _count("SELECT COUNT(*) FROM sites"), - "collectors": await _count("SELECT COUNT(*) FROM collectors"), - "collectors_enabled": await _count("SELECT COUNT(*) FROM collectors WHERE enabled = 1"), - "unacknowledged_alerts": await _count("SELECT COUNT(*) FROM alert_events WHERE acked = 0"), - } - - -@router.get( - f"{DATA_PREFIX}/access-points", - operation_id="listAccessPoints", - summary="List the access points", - description=( - "The access points pktWiFi knows about — where each is, what it is, whether it is " - "answering, and how many clients are on it. Set rogue_only for access points that are " - "not ours, which is a security question rather than a capacity one. Every filter is " - "optional and they combine with AND. Returns at most `limit` access points plus the " - "total that matched." - ), - response_model=AccessPointList, - responses=_ERRORS, -) -async def list_access_points( - _user: dict = SessionUser, - status: Optional[ApStatus] = Query(None, description="Only access points in this state."), - site: Optional[str] = Query(None, max_length=120, description="Only access points at this site."), - rogue_only: bool = Query(False, description="Only access points flagged as not ours."), - search: Optional[str] = Query( - None, max_length=200, description="Substring of the name, MAC, address or model." - ), - limit: int = Query( - _SEARCH_DEFAULT, ge=1, le=_SEARCH_MAX, - description=f"How many to return. Default {_SEARCH_DEFAULT}, maximum {_SEARCH_MAX}.", - ), - offset: int = Query(0, ge=0, description="How many to skip, for paging."), - db: aiosqlite.Connection = Depends(get_db), -): - clauses: list[str] = [] - params: list = [] - if status: - clauses.append("a.status = ?") - params.append(status) - if site: - clauses.append("a.site = ?") - params.append(site) - if rogue_only: - clauses.append("a.is_rogue = 1") - if search: - clauses.append("(a.name LIKE ? OR a.mac_address LIKE ? OR a.ip_address LIKE ? OR a.model LIKE ?)") - like = f"%{search}%" - params.extend([like] * 4) - where = ("WHERE " + " AND ".join(clauses)) if clauses else "" - - async with db.execute(f"SELECT COUNT(*) FROM access_points a {where}", params) as cur: - total = (await cur.fetchone())[0] - - async with db.execute( - f"""SELECT a.id, a.name, a.mac_address, a.ip_address, a.vendor, a.model, - a.firmware_version, a.site, a.floor, a.status, a.is_rogue, - a.uptime_seconds, a.collector_id, a.last_seen, - (SELECT COUNT(*) FROM wifi_clients c WHERE c.access_point_id = a.id AND {client_is_current(ap='a')}) - AS client_count - FROM access_points a - {where} - ORDER BY a.name - LIMIT ? OFFSET ?""", - params + [limit, offset], - ) as cur: - rows = await cur.fetchall() - - aps = [] - for r in rows: - d = dict(r) - d["is_rogue"] = bool(d.get("is_rogue")) - aps.append(d) - - return _fit( - {"total": total, "limit": limit, "offset": offset, "access_points": aps}, - "access_points", - ) - - -@router.get( - f"{DATA_PREFIX}/access-points/{{ap_id}}", - operation_id="getAccessPoint", - summary="Read one access point in full", - description=( - "Everything pktWiFi records about a single access point, by the id listAccessPoints " - "returned. Use this after a search when the question is about one AP in particular — " - "its firmware, its uptime, how many clients it is carrying." - ), - response_model=AccessPoint, - responses={**_ERRORS, 404: {"model": ErrorResponse, "description": "No access point with that id."}}, -) -async def get_access_point( - ap_id: int = Path(description="Id of the access point, as returned by listAccessPoints."), - _user: dict = SessionUser, - db: aiosqlite.Connection = Depends(get_db), -): - async with db.execute( - f"""SELECT a.id, a.name, a.mac_address, a.ip_address, a.vendor, a.model, - a.firmware_version, a.site, a.floor, a.status, a.is_rogue, - a.uptime_seconds, a.collector_id, a.last_seen, - (SELECT COUNT(*) FROM wifi_clients c WHERE c.access_point_id = a.id AND {client_is_current(ap='a')}) - AS client_count - FROM access_points a WHERE a.id = ?""", - (ap_id,), - ) as cur: - row = await cur.fetchone() - if not row: - raise ResonanceDataError(status_code=404, detail=f"There is no access point {ap_id}.") - d = dict(row) - d["is_rogue"] = bool(d.get("is_rogue")) - return d - - -@router.get( - f"{DATA_PREFIX}/clients", - operation_id="listWifiClients", - summary="List associated wireless clients", - description=( - "The clients pktWiFi last saw associated, with the access point and SSID each is on and " - "how good their radio link is. This is the 'why is that laptop slow on wifi' answer: " - "rssi_dbm closer to zero is stronger and below -70 is poor, snr_db under 20 is poor. " - "Weakest signal first when no other order is implied. Every filter is optional." - ), - response_model=WifiClientList, - responses=_ERRORS, -) -async def list_wifi_clients( - _user: dict = SessionUser, - access_point_id: Optional[int] = Query(None, description="Only clients on this access point."), - ssid: Optional[str] = Query(None, max_length=120, description="Only clients on this SSID."), - band: Optional[Band] = Query(None, description="Only clients on this band, in GHz."), - weak_signal_only: bool = Query( - False, description="Only clients at or below -70 dBm — the ones actually having a bad time." - ), - search: Optional[str] = Query( - None, max_length=200, description="Substring of the MAC, hostname or address." - ), - limit: int = Query( - _SEARCH_DEFAULT, ge=1, le=_SEARCH_MAX, - description=f"How many to return. Default {_SEARCH_DEFAULT}, maximum {_SEARCH_MAX}.", - ), - offset: int = Query(0, ge=0, description="How many to skip, for paging."), - db: aiosqlite.Connection = Depends(get_db), -): - clauses: list[str] = [] - params: list = [] - if access_point_id is not None: - clauses.append("c.access_point_id = ?") - params.append(access_point_id) - if ssid: - clauses.append("c.ssid = ?") - params.append(ssid) - if band: - clauses.append("c.band = ?") - params.append(band) - if weak_signal_only: - clauses.append("c.rssi_dbm IS NOT NULL AND c.rssi_dbm <= -70") - if search: - clauses.append("(c.mac_address LIKE ? OR c.hostname LIKE ? OR c.ip_address LIKE ?)") - like = f"%{search}%" - params.extend([like] * 3) - # Only clients on air now; the tables keep rows a poll no longer reports. - clauses.append(client_is_current(ap="a")) - where = "WHERE " + " AND ".join(clauses) - - async with db.execute( - f"SELECT COUNT(*) FROM wifi_clients c JOIN access_points a ON a.id = c.access_point_id {where}", - params, - ) as cur: - total = (await cur.fetchone())[0] - - async with db.execute( - f"""SELECT c.id, c.mac_address, c.hostname, c.ip_address, c.ssid, c.band, - c.protocol, c.rssi_dbm, c.snr_db, c.tx_rate_mbps, c.rx_rate_mbps, - c.access_point_id, c.connected_at, c.last_seen, - a.name AS access_point_name - FROM wifi_clients c - JOIN access_points a ON a.id = c.access_point_id - {where} - ORDER BY c.rssi_dbm ASC - LIMIT ? OFFSET ?""", - params + [limit, offset], - ) as cur: - rows = await cur.fetchall() - - return _fit( - {"total": total, "limit": limit, "offset": offset, "clients": [dict(r) for r in rows]}, - "clients", - ) - - -@router.get( - f"{DATA_PREFIX}/radios", - operation_id="listRadios", - summary="List access-point radios, with channel and congestion", - description=( - "One row per radio: its band, channel, width, transmit power, how busy the channel is " - "and what the noise floor looks like. This is the 'is the 2.4 band congested' and 'what " - "channel is that AP on' answer — utilization_pct sustained above 50 is congestion. " - "Busiest first." - ), - response_model=RadioList, - responses=_ERRORS, -) -async def list_radios( - _user: dict = SessionUser, - access_point_id: Optional[int] = Query(None, description="Only radios on this access point."), - band: Optional[Band] = Query(None, description="Only radios on this band, in GHz."), - db: aiosqlite.Connection = Depends(get_db), -): - clauses: list[str] = [] - params: list = [] - if access_point_id is not None: - clauses.append("r.access_point_id = ?") - params.append(access_point_id) - if band: - clauses.append("r.band = ?") - params.append(band) - clauses.append(radio_is_current(ap="a")) - where = "WHERE " + " AND ".join(clauses) - - async with db.execute( - f"""SELECT r.id, r.access_point_id, r.band, r.channel, r.channel_width_mhz, - r.tx_power_dbm, r.utilization_pct, r.noise_floor_dbm, r.client_count, - r.updated_at, a.name AS access_point_name - FROM radios r - JOIN access_points a ON a.id = r.access_point_id - {where} - ORDER BY r.utilization_pct DESC""", - params, - ) as cur: - rows = await cur.fetchall() - radios = [dict(r) for r in rows] - return _fit({"total": len(radios), "radios": radios}, "radios") - - -@router.get( - f"{DATA_PREFIX}/collectors", - operation_id="listCollectors", - summary="List the collectors pktWiFi polls", - description=( - "Where pktWiFi's data comes from — which controller each collector talks to, whether it " - "is enabled, when it last ran and why it failed if it did. A collector that stopped " - "polling explains every access point going quiet at once, so read this before " - "concluding the wireless itself is down. No controller credential is returned." - ), - response_model=CollectorList, - responses=_ERRORS, -) -async def list_collectors( - _user: dict = SessionUser, - db: aiosqlite.Connection = Depends(get_db), -): - # config_json holds the controller credentials and is deliberately not selected. - async with db.execute( - "SELECT id, name, collector_type, enabled, status, poll_interval_sec, " - "last_poll_at, last_error FROM collectors ORDER BY name" - ) as cur: - rows = await cur.fetchall() - collectors = [] - for r in rows: - d = dict(r) - d["enabled"] = bool(d.get("enabled")) - collectors.append(d) - return _fit({"total": len(collectors), "collectors": collectors}, "collectors") - - -@router.get( - f"{DATA_PREFIX}/alerts/events", - operation_id="listAlertEvents", - summary="List alerts that have fired", - description=( - "Individual firings of pktWiFi's alert rules — an access point going offline, a rogue " - "appearing, a channel saturating — newest first. This is what to read for 'what is " - "wrong' or 'what happened overnight'. An event with acked false is one nobody has looked " - "at yet; active true means the condition still holds." - ), - response_model=AlertEventList, - responses=_ERRORS, -) -async def list_alert_events( - _user: dict = SessionUser, - unacked_only: bool = Query(False, description="Only events nobody has acknowledged yet."), - active_only: bool = Query(False, description="Only events whose condition still holds."), - severity: Optional[AlertSeverity] = Query(None, description="Only events raised at this severity."), - access_point_id: Optional[int] = Query(None, description="Only events about this access point."), - since: Optional[str] = Query(None, description="Only events fired at or after this time. ISO 8601."), - until: Optional[str] = Query(None, description="Only events fired at or before this time. ISO 8601."), - limit: int = Query( - _SEARCH_DEFAULT, ge=1, le=_SEARCH_MAX, - description=f"How many to return. Default {_SEARCH_DEFAULT}, maximum {_SEARCH_MAX}.", - ), - offset: int = Query(0, ge=0, description="How many to skip, for paging."), - db: aiosqlite.Connection = Depends(get_db), -): - clauses: list[str] = [] - params: list = [] - if unacked_only: - clauses.append("e.acked = 0") - if active_only: - clauses.append("e.active = 1") - if severity: - clauses.append("e.severity = ?") - params.append(severity) - if access_point_id is not None: - clauses.append("e.access_point_id = ?") - params.append(access_point_id) - if since: - # created_at is written by SQLite's datetime('now') — space separated, - # no 'Z' — so both sides go through datetime() to compare like for like. - clauses.append("e.created_at >= datetime(?)") - params.append(since) - if until: - clauses.append("e.created_at <= datetime(?)") - params.append(until) - where = ("WHERE " + " AND ".join(clauses)) if clauses else "" - - async with db.execute(f"SELECT COUNT(*) FROM alert_events e {where}", params) as cur: - total = (await cur.fetchone())[0] - - async with db.execute( - f"""SELECT e.id, e.rule_id, e.access_point_id, e.client_mac, e.severity, e.message, - e.value, e.threshold, e.active, e.acked, e.acked_by, e.acked_at, - e.resolved, e.resolved_at, e.created_at, r.name AS rule_name - FROM alert_events e - LEFT JOIN alert_rules r ON r.id = e.rule_id - {where} - ORDER BY e.created_at DESC - LIMIT ? OFFSET ?""", - params + [limit, offset], - ) as cur: - rows = await cur.fetchall() - - events = [] - for r in rows: - d = dict(r) - for flag in ("active", "acked", "resolved"): - d[flag] = bool(d.get(flag)) - events.append(d) - - return _fit({"total": total, "limit": limit, "offset": offset, "events": events}, "events") - - -@router.get( - f"{DATA_PREFIX}/alerts/rules", - operation_id="listAlertRules", - summary="List the configured alert rules", - description=( - "The rules an administrator has set up, whether each is switched on, what it watches and " - "at what threshold. Rules are the configuration; listAlertEvents is what they have " - "actually fired. Read this to answer 'are we even watching for that'. Switching a rule " - "is not something the assistant can do in pktWiFi — that is an administrator's to do in " - "the interface." - ), - response_model=AlertRuleList, - responses=_ERRORS, -) -async def list_alert_rules( - _user: dict = SessionUser, - enabled_only: bool = Query(False, description="Only rules that are currently switched on."), - db: aiosqlite.Connection = Depends(get_db), -): - where = "WHERE enabled = 1" if enabled_only else "" - async with db.execute( - f"SELECT id, name, condition_type, threshold, severity, enabled, created_at, channels " - f"FROM alert_rules {where} ORDER BY name" - ) as cur: - rows = await cur.fetchall() - rules = [] - for r in rows: - d = dict(r) - d["enabled"] = bool(d.get("enabled")) - # Stored as a JSON array; hand it over decoded rather than as a string - # the reader would have to parse itself. - try: - parsed = json.loads(d.get("channels") or "[]") - except (ValueError, TypeError): - parsed = [] - d["channels"] = parsed if isinstance(parsed, list) else [] - rules.append(d) - return _fit({"total": len(rules), "rules": rules}, "rules") - - -@router.get( - f"{DATA_PREFIX}/app-log", - operation_id="searchApplicationLog", - summary="Search pktWiFi's own diagnostic log", - description=( - "pktWiFi's internal log — what the application itself did and any errors it hit. This is " - "NOT wireless data: for access points use listAccessPoints, for clients use " - "listWifiClients, and for alert firings use listAlertEvents. Read this to answer 'why " - "did the controller poll stop'. Newest first." - ), - response_model=AppLogResult, - responses=_ERRORS, -) -async def search_application_log( - _user: dict = SessionUser, - level: Optional[LogLevel] = Query(None, description="Only lines at this level."), - logger: Optional[str] = Query( - None, max_length=120, description="Only lines from loggers with this prefix." - ), - search: Optional[str] = Query(None, max_length=200, description="Substring of the message."), - since: Optional[str] = Query(None, description="Only lines at or after this time. ISO 8601."), - until: Optional[str] = Query(None, description="Only lines at or before this time. ISO 8601."), - limit: int = Query( - _SEARCH_DEFAULT, ge=1, le=_SEARCH_MAX, - description=f"How many to return. Default {_SEARCH_DEFAULT}, maximum {_SEARCH_MAX}.", - ), - offset: int = Query(0, ge=0, description="How many to skip, for paging."), - db: aiosqlite.Connection = Depends(get_db), -): - clauses: list[str] = [] - params: list = [] - if level: - clauses.append("level = ?") - params.append(level) - if logger: - clauses.append("logger LIKE ?") - params.append(f"{logger}%") - if search: - clauses.append("message LIKE ?") - params.append(f"%{search}%") - if since: - clauses.append("created_at >= ?") - params.append(since) - if until: - clauses.append("created_at <= ?") - params.append(until) - where = ("WHERE " + " AND ".join(clauses)) if clauses else "" - - async with db.execute(f"SELECT COUNT(*) FROM app_logs {where}", params) as cur: - total = (await cur.fetchone())[0] - - async with db.execute( - f"SELECT id, level, logger, message, created_at FROM app_logs {where} " - "ORDER BY id DESC LIMIT ? OFFSET ?", - params + [limit, offset], - ) as cur: - rows = await cur.fetchall() - - return _fit( - {"total": total, "limit": limit, "offset": offset, "records": [dict(r) for r in rows]}, - "records", - ) - - -# ── The two documents ───────────────────────────────────────────────────────── -# -# Neither carries data — only names — so both are readable without a login, in -# the same way this app already publishes its own /openapi.json. Publishing them -# grants nothing on its own: an operation is reachable only because it is in -# GRANTED, and reachable only to a signed-in person whose role an admin listed. - - -def _declared_operation_ids(app) -> set[str]: - """operationIds actually registered on the app. - - Walks the route table rather than calling app.openapi(), which would build - and cache the schema at import time — before the SPA catch-all is mounted. - - The walk recurses because the table is not reliably flat: recent FastAPI - keeps an included router as a single wrapper object holding its own routes, - where earlier versions spliced them straight in. pkt installs pin only a - lower bound on fastapi, so both layouts are live in the field and a walker - that understood one of them would have reported every operation missing on - the other. - """ - found: set[str] = set() - seen: set[int] = set() - - def walk(routes) -> None: - for route in routes or []: - if id(route) in seen: - continue - seen.add(id(route)) - op = getattr(route, "operation_id", None) - if op: - found.add(op) - nested = getattr(route, "routes", None) - if nested is None: - inner = getattr(route, "original_router", None) - nested = getattr(inner, "routes", None) if inner is not None else None - if nested: - walk(nested) - - walk(getattr(app, "routes", [])) - return found - - -def validate_grants(app) -> list[str]: - """Fail loudly at startup when a grant names an operation that is not there. - - A grant for a route that has been renamed is the quiet failure mode of this - whole arrangement: the panel asks for it, gets a 404, and reports the app as - having no such capability rather than as misconfigured. Returns the missing - names so a caller can act on them; logs them either way. - """ - declared = _declared_operation_ids(app) - missing = [g.op for g in GRANTED if g.op not in declared] - if missing: - log.error( - "resonance grant names %d operation(s) this app does not declare: %s — " - "they are being withheld from /.well-known/resonance.json", - len(missing), ", ".join(missing), - ) - return missing - - -async def writes_are_enabled(db: aiosqlite.Connection) -> bool: - """True when at least one role has been trusted with more than reading. - - The grant is one document for the whole origin and is served without a - login, so it cannot vary per person — but it can tell the truth about the - install. Where no role is set to "write", the write operations are withheld - from it entirely rather than advertised and refused on every attempt. - """ - for role in ("admin", "analyst", "viewer"): - if LEVEL_RANK.get(await role_level(db, role), 0) >= LEVEL_RANK["write"]: - return True - return False - - -def build_grant(app, allow_writes: bool) -> dict: - """The grant document, generated from GRANTED so the two cannot disagree.""" - declared = _declared_operation_ids(app) - allow: list[dict] = [] - for g in GRANTED: - if g.op not in declared: - continue - if g.writes and not allow_writes: - continue - entry: dict[str, Any] = {"op": g.op} - if g.writes: - entry["writes"] = True - allow.append(entry) - return {"resonance": 1, "spec": SPEC_PATH, "allow": allow} - - -def _referenced_schemas(node: Any, out: set[str]) -> None: - if isinstance(node, dict): - ref = node.get("$ref") - if isinstance(ref, str) and ref.startswith("#/components/schemas/"): - out.add(ref.rsplit("/", 1)[-1]) - for value in node.values(): - _referenced_schemas(value, out) - elif isinstance(node, list): - for value in node: - _referenced_schemas(value, out) - - -def build_spec(app, allow_writes: bool) -> dict: - """This app's own OpenAPI, narrowed to the granted operations. - - Generated from the live routes rather than written by hand, so a parameter - that changes shape changes here too — the failure a hand-kept spec always - ends in is the assistant confidently sending a field that stopped existing. - Narrowed rather than published whole because everything an operation's prose - has to compete with is another operation's prose: a hundred and twenty of - them, most of which the grant forbids, is a hundred and twenty chances to - pick the wrong one. - """ - full = app.openapi() - granted = {g.op for g in GRANTED if allow_writes or not g.writes} - - paths: dict[str, dict] = {} - for path, item in (full.get("paths") or {}).items(): - # Deep-copied because app.openapi() hands back the app's own cached - # schema object: editing an operation in place here would edit the - # document this app publishes at /openapi.json as well. - kept = { - method: copy.deepcopy(operation) - for method, operation in item.items() - if isinstance(operation, dict) and operation.get("operationId") in granted - } - if kept: - for operation in kept.values(): - # Nothing is presented on these calls but the person's own - # session cookie, which the browser attaches by itself. - operation.pop("security", None) - paths[path] = kept - - wanted: set[str] = set() - _referenced_schemas(paths, wanted) - all_schemas = (full.get("components") or {}).get("schemas") or {} - resolved: dict[str, Any] = {} - while wanted: - name = wanted.pop() - if name in resolved or name not in all_schemas: - continue - resolved[name] = copy.deepcopy(all_schemas[name]) - nested: set[str] = set() - _referenced_schemas(all_schemas[name], nested) - wanted |= nested - resolved.keys() - - spec: dict[str, Any] = { - "openapi": full.get("openapi", "3.1.0"), - "info": { - "title": "pktWiFi — assistant data surface", - "version": full.get("info", {}).get("version", "0.1.0"), - "description": ( - "The operations pktWiFi publishes for an embedded assistant. Every call is made " - "by pktWiFi's own page, same-origin, on the session of the person already signed " - "in, so nothing here can reach data that person could not already open in the " - "interface. No private key, passcode or certificate PEM is exposed, and nothing " - "here issues, revokes, signs or approves anything." - ), - }, - "paths": paths, - } - if resolved: - spec["components"] = {"schemas": resolved} - return spec - - -# Two possible documents — with writes and without — so the setting can change -# without a restart while the expensive part is still built once each. -_spec_cache: dict[bool, Any] = {} - - -@router.get(GRANT_PATH, include_in_schema=False) -async def resonance_grant(request: Request, db: aiosqlite.Connection = Depends(get_db)): - """What this install permits the assistant to call. Names only, no data. - - Public by contract: it has to be readable before anyone signs in, and it - carries nothing but operation names. Whether the write operations appear - depends on the levels an admin set, so an install that has trusted nobody - with writes publishes a grant that cannot be read as offering them. - """ - grant = build_grant(request.app, await writes_are_enabled(db)) - log.info("resonance grant fetched: %d operation(s), %d writing", - len(grant["allow"]), sum(1 for a in grant["allow"] if a.get("writes"))) - return JSONResponse( - grant, - media_type="application/json", - headers={"Cache-Control": "public, max-age=300"}, - ) - - -@router.get(SPEC_PATH, include_in_schema=False) -async def resonance_spec(request: Request, db: aiosqlite.Connection = Depends(get_db)): - """The OpenAPI document for the granted operations.""" - allow_writes = await writes_are_enabled(db) - if allow_writes not in _spec_cache: - _spec_cache[allow_writes] = build_spec(request.app, allow_writes) - log.info("resonance spec fetched (writes %s)", "included" if allow_writes else "withheld") - return JSONResponse( - _spec_cache[allow_writes], - media_type="application/json", - headers={"Cache-Control": "public, max-age=300"}, - ) - - -# ── Operations that change something ────────────────────────────────────────── -# -# Every one of these is marked `writes: true` in the grant, so resonance stops -# and reads the actual values back to the person before it runs one. That -# confirmation is theirs to enforce and cannot be relied on here, which is why -# both gates above still apply on the request itself. -# -# What is deliberately absent is as much of the design as what is present: no -# delete of anything, no clearing of logs, and no creating or editing of -# configuration. An assistant can act on what an administrator already put -# there, and cannot author or destroy it. - - -class AckResult(BaseModel): - model_config = ConfigDict(extra="allow") - event_id: int = Field(description="The alert event this refers to.") - acknowledged: bool = Field(description="True if this call acknowledged it.") - already_acknowledged: bool = Field( - description="True when someone had already acknowledged it, in which case nothing changed." - ) - acked_at: Optional[str] = Field(None, description="When it was acknowledged (ISO 8601, UTC).") - message: str = Field(description="What happened, phrased to be read back to the person.") - - -class AckAllResult(BaseModel): - model_config = ConfigDict(extra="allow") - acknowledged: int = Field(description="How many outstanding alerts this call acknowledged.") - message: str = Field(description="What happened, phrased to be read back to the person.") - - -@router.post( - f"{DATA_PREFIX}/alerts/events/{{event_id}}/ack", - operation_id="ackAlertEvent", - summary="Acknowledge one alert", - description=( - "Mark a single fired alert as seen, recording who did it and when. This changes state. It " - "does not resolve the alert or fix the condition behind it — a certificate close to " - "expiry is still close to expiry, and the rule will fire again. Acknowledging something " - "already acknowledged changes nothing and says so. Available to analysts and " - "administrators, as in the interface." - ), - response_model=AckResult, - responses={**_ERRORS, 404: {"model": ErrorResponse, "description": "No alert event with that id."}}, -) -async def ack_alert_event( - event_id: int = Path( - description="Id of the alert event to acknowledge, as returned by listAlertEvents." - ), - user: dict = WriteUser, - db: aiosqlite.Connection = Depends(get_db), -): - await _apply_app_rule(user, require_analyst, "acknowledge alerts") - - async with db.execute( - "SELECT e.acked, e.acked_at, r.name FROM alert_events e " - "LEFT JOIN alert_rules r ON r.id = e.rule_id WHERE e.id = ?", - (event_id,), - ) as cur: - row = await cur.fetchone() - if not row: - raise ResonanceDataError(status_code=404, detail=f"There is no alert event {event_id}.") - - name = row["name"] or "unnamed rule" - if row["acked"]: - when = str(row["acked_at"] or "").replace(" ", "T") + "Z" if row["acked_at"] else None - return { - "event_id": event_id, "acknowledged": False, "already_acknowledged": True, - "acked_at": when, - "message": f"Alert {event_id} ({name}) was already acknowledged" - + (f" at {when}." if when else "."), - } - - await db.execute( - "UPDATE alert_events SET acked = 1, acked_by = ?, acked_at = datetime('now') " - "WHERE id = ? AND acked = 0", - (user.get("username"), event_id), - ) - await db.commit() - - async with db.execute("SELECT acked_at FROM alert_events WHERE id = ?", (event_id,)) as cur: - acked = (await cur.fetchone())["acked_at"] - when = str(acked).replace(" ", "T") + "Z" if acked else None - log.info("resonance: %s acknowledged alert event %s", user.get("username"), event_id) - return { - "event_id": event_id, "acknowledged": True, "already_acknowledged": False, - "acked_at": when, - "message": f"Acknowledged alert {event_id} ({name}). The condition behind it is unchanged.", - } - - -@router.post( - f"{DATA_PREFIX}/alerts/events/ack-all", - operation_id="ackAllAlertEvents", - summary="Acknowledge every outstanding alert", - description=( - "Mark every alert nobody has acknowledged yet as seen, in one go. This changes state, and " - "it is not reversible from here — there is no un-acknowledge. It resolves nothing: every " - "condition behind every alert is untouched. Reports how many were acknowledged, which is " - "zero when there was nothing outstanding. Available to analysts and administrators, as in " - "the interface." - ), - response_model=AckAllResult, - responses=_ERRORS, -) -async def ack_all_alert_events( - user: dict = WriteUser, - db: aiosqlite.Connection = Depends(get_db), -): - await _apply_app_rule(user, require_analyst, "acknowledge alerts") - - async with db.execute("SELECT COUNT(*) FROM alert_events WHERE acked = 0") as cur: - outstanding = (await cur.fetchone())[0] - if not outstanding: - return {"acknowledged": 0, "message": "There were no unacknowledged alerts."} - - await db.execute( - "UPDATE alert_events SET acked = 1, acked_by = ?, acked_at = datetime('now') " - "WHERE acked = 0", - (user.get("username"),), - ) - await db.commit() - log.info("resonance: %s acknowledged all %d outstanding alerts", - user.get("username"), outstanding) - return { - "acknowledged": outstanding, - "message": f"Acknowledged {outstanding} alert" - f"{'' if outstanding == 1 else 's'}. None of the conditions behind them changed.", - } - - diff --git a/app/api/resonance_data/__init__.py b/app/api/resonance_data/__init__.py new file mode 100644 index 0000000..f4a8dd4 --- /dev/null +++ b/app/api/resonance_data/__init__.py @@ -0,0 +1,44 @@ +""" +app/api/resonance_data/ — the data half of the resonance contract. + +app/api/resonance.py mounts the panel. This module is what the panel is +allowed to *read* once it is mounted, and it exists because the embed contract +has three parts and mounting only satisfies one of them: + + 1. an OpenAPI document at a stable same-origin path -> /api/resonance/openapi.json + 2. a grant file naming what may be called -> /.well-known/resonance.json + 3. endpoints that behave: bounded, JSON, stable fields -> /api/resonance/data/* + +Why a separate surface rather than granting against /api/access-points/* and friends. +The operations named in a grant have to carry a stable operationId, prose a +stranger can choose between, enums for every fixed vocabulary, a declared +response schema, and a bounded page with a total. pktWiFi's own endpoints were +written for a SPA that already knows all of that: most return a bare array with +no total and no paging. Retrofitting the contract onto them would change +response shapes the frontend already consumes. These wrap the same tables +instead, so there is no second implementation of any query — only a second, +narrower doorway with the labels the model needs. + +Authentication is the app's existing session, not a new one. The panel's calls +are ordinary same-origin fetches from our own page, so they carry the refresh +cookie exactly as /api/resonance/code does, and they are admitted by the same +helpers that admit /code — see resonance_session_user below. Nothing here +issues, accepts or understands a credential of resonance's, and the panel can +therefore only ever read what the signed-in person could already read. + +WHAT IS DELIBERATELY ABSENT IS PART OF THE DESIGN. A collector's stored +configuration — the controller credentials it polls with — is never selected, so +it cannot reach the assistant through a schema's `extra` either. Nothing here +creates, edits or deletes an access point, an SSID, a radio or a collector, +nothing changes a channel or a transmit power, and nothing deauthenticates a +client. Acknowledging an alert is the only thing that changes state, and +pktWiFi's own interface has no rule on/off switch for an assistant to mirror. +""" +from __future__ import annotations + +from app.api.resonance_data._common import router # noqa: F401 +from app.api.resonance_data.errors import register_error_handler # noqa: F401 +from app.api.resonance_data.documents import validate_grants # noqa: F401 + +# Importing these registers their routes on the shared router. +from app.api.resonance_data import read, documents, writes # noqa: E402,F401 diff --git a/app/api/resonance_data/_common.py b/app/api/resonance_data/_common.py new file mode 100644 index 0000000..00f9800 --- /dev/null +++ b/app/api/resonance_data/_common.py @@ -0,0 +1,80 @@ +""" +The shared router, the paths of the contract, the operations a grant may name, and the fixed vocabularies. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Literal + +from fastapi import APIRouter + + +log = logging.getLogger("pktwifi.api.resonance_data") + + +router = APIRouter(tags=["resonance-data"]) + + +DATA_PREFIX = "/api/resonance/data" + + +SPEC_PATH = "/api/resonance/openapi.json" + + +GRANT_PATH = "/.well-known/resonance.json" + + +# ── What the assistant is allowed to call ──────────────────────────────────── +# +# The one list. The grant file is generated from it, the published spec is +# filtered to it, and startup checks it against the routes that actually exist. +# An operationId that is not here is invisible to the assistant even though it +# is a perfectly ordinary route of this app. + + +@dataclass(frozen=True) +class Grant: + op: str + # Set on ANY operation that changes state, whatever its HTTP verb. + # Resonance reads the values back to the person before running one. + writes: bool = False + + +GRANTED: tuple[Grant, ...] = ( + Grant("getWifiSummary"), + Grant("listAccessPoints"), + Grant("getAccessPoint"), + Grant("listWifiClients"), + Grant("listRadios"), + Grant("listCollectors"), + Grant("listAlertEvents"), + Grant("listAlertRules"), + Grant("searchApplicationLog"), + # The only state change on offer. There is deliberately no channel or power + # change, no client deauthentication, and no create, edit or delete of + # anything: an assistant may acknowledge what an administrator is already + # being told about, and nothing else. + Grant("ackAlertEvent", writes=True), + Grant("ackAllAlertEvents", writes=True), +) + + +# ── Vocabulary ──────────────────────────────────────────────────────────────── +# +# These are the enums the requirement is really about: without them a model asks +# for a band of "5G" or a status of "offline", gets a 422, and reports the app +# as broken. All of these are fixed in pktWiFi's own code — the +# install-specific vocabulary (AP names, SSIDs, site names) cannot be, and is +# published through listAccessPoints and listWifiClients instead. + +ApStatus = Literal["online", "offline", "unknown"] + + +Band = Literal["2.4", "5", "6"] + + +AlertSeverity = Literal["info", "warning", "critical"] + + +LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] diff --git a/app/api/resonance_data/documents.py b/app/api/resonance_data/documents.py new file mode 100644 index 0000000..d941800 --- /dev/null +++ b/app/api/resonance_data/documents.py @@ -0,0 +1,221 @@ +""" +The two documents the contract asks for: the grant file and the OpenAPI spec, built from the routes themselves. +""" +from __future__ import annotations + +import copy +from typing import Any + +import aiosqlite +from fastapi import Depends, Request +from fastapi.responses import JSONResponse + +from app.api.resonance import LEVEL_RANK, role_level +from app.api.resonance_data._common import GRANTED, GRANT_PATH, SPEC_PATH, log, router +from app.database import get_db + + +# ── The two documents ───────────────────────────────────────────────────────── +# +# Neither carries data — only names — so both are readable without a login, in +# the same way this app already publishes its own /openapi.json. Publishing them +# grants nothing on its own: an operation is reachable only because it is in +# GRANTED, and reachable only to a signed-in person whose role an admin listed. + + +def _declared_operation_ids(app) -> set[str]: + """operationIds actually registered on the app. + + Walks the route table rather than calling app.openapi(), which would build + and cache the schema at import time — before the SPA catch-all is mounted. + + The walk recurses because the table is not reliably flat: recent FastAPI + keeps an included router as a single wrapper object holding its own routes, + where earlier versions spliced them straight in. pkt installs pin only a + lower bound on fastapi, so both layouts are live in the field and a walker + that understood one of them would have reported every operation missing on + the other. + """ + found: set[str] = set() + seen: set[int] = set() + + def walk(routes) -> None: + for route in routes or []: + if id(route) in seen: + continue + seen.add(id(route)) + op = getattr(route, "operation_id", None) + if op: + found.add(op) + nested = getattr(route, "routes", None) + if nested is None: + inner = getattr(route, "original_router", None) + nested = getattr(inner, "routes", None) if inner is not None else None + if nested: + walk(nested) + + walk(getattr(app, "routes", [])) + return found + + +def validate_grants(app) -> list[str]: + """Fail loudly at startup when a grant names an operation that is not there. + + A grant for a route that has been renamed is the quiet failure mode of this + whole arrangement: the panel asks for it, gets a 404, and reports the app as + having no such capability rather than as misconfigured. Returns the missing + names so a caller can act on them; logs them either way. + """ + declared = _declared_operation_ids(app) + missing = [g.op for g in GRANTED if g.op not in declared] + if missing: + log.error( + "resonance grant names %d operation(s) this app does not declare: %s — " + "they are being withheld from /.well-known/resonance.json", + len(missing), ", ".join(missing), + ) + return missing + + +async def writes_are_enabled(db: aiosqlite.Connection) -> bool: + """True when at least one role has been trusted with more than reading. + + The grant is one document for the whole origin and is served without a + login, so it cannot vary per person — but it can tell the truth about the + install. Where no role is set to "write", the write operations are withheld + from it entirely rather than advertised and refused on every attempt. + """ + for role in ("admin", "analyst", "viewer"): + if LEVEL_RANK.get(await role_level(db, role), 0) >= LEVEL_RANK["write"]: + return True + return False + + +def build_grant(app, allow_writes: bool) -> dict: + """The grant document, generated from GRANTED so the two cannot disagree.""" + declared = _declared_operation_ids(app) + allow: list[dict] = [] + for g in GRANTED: + if g.op not in declared: + continue + if g.writes and not allow_writes: + continue + entry: dict[str, Any] = {"op": g.op} + if g.writes: + entry["writes"] = True + allow.append(entry) + return {"resonance": 1, "spec": SPEC_PATH, "allow": allow} + + +def _referenced_schemas(node: Any, out: set[str]) -> None: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + out.add(ref.rsplit("/", 1)[-1]) + for value in node.values(): + _referenced_schemas(value, out) + elif isinstance(node, list): + for value in node: + _referenced_schemas(value, out) + + +def build_spec(app, allow_writes: bool) -> dict: + """This app's own OpenAPI, narrowed to the granted operations. + + Generated from the live routes rather than written by hand, so a parameter + that changes shape changes here too — the failure a hand-kept spec always + ends in is the assistant confidently sending a field that stopped existing. + Narrowed rather than published whole because everything an operation's prose + has to compete with is another operation's prose: a hundred and twenty of + them, most of which the grant forbids, is a hundred and twenty chances to + pick the wrong one. + """ + full = app.openapi() + granted = {g.op for g in GRANTED if allow_writes or not g.writes} + + paths: dict[str, dict] = {} + for path, item in (full.get("paths") or {}).items(): + # Deep-copied because app.openapi() hands back the app's own cached + # schema object: editing an operation in place here would edit the + # document this app publishes at /openapi.json as well. + kept = { + method: copy.deepcopy(operation) + for method, operation in item.items() + if isinstance(operation, dict) and operation.get("operationId") in granted + } + if kept: + for operation in kept.values(): + # Nothing is presented on these calls but the person's own + # session cookie, which the browser attaches by itself. + operation.pop("security", None) + paths[path] = kept + + wanted: set[str] = set() + _referenced_schemas(paths, wanted) + all_schemas = (full.get("components") or {}).get("schemas") or {} + resolved: dict[str, Any] = {} + while wanted: + name = wanted.pop() + if name in resolved or name not in all_schemas: + continue + resolved[name] = copy.deepcopy(all_schemas[name]) + nested: set[str] = set() + _referenced_schemas(all_schemas[name], nested) + wanted |= nested - resolved.keys() + + spec: dict[str, Any] = { + "openapi": full.get("openapi", "3.1.0"), + "info": { + "title": "pktWiFi — assistant data surface", + "version": full.get("info", {}).get("version", "0.1.0"), + "description": ( + "The operations pktWiFi publishes for an embedded assistant. Every call is made " + "by pktWiFi's own page, same-origin, on the session of the person already signed " + "in, so nothing here can reach data that person could not already open in the " + "interface. No private key, passcode or certificate PEM is exposed, and nothing " + "here issues, revokes, signs or approves anything." + ), + }, + "paths": paths, + } + if resolved: + spec["components"] = {"schemas": resolved} + return spec + + +# Two possible documents — with writes and without — so the setting can change +# without a restart while the expensive part is still built once each. +_spec_cache: dict[bool, Any] = {} + + +@router.get(GRANT_PATH, include_in_schema=False) +async def resonance_grant(request: Request, db: aiosqlite.Connection = Depends(get_db)): + """What this install permits the assistant to call. Names only, no data. + + Public by contract: it has to be readable before anyone signs in, and it + carries nothing but operation names. Whether the write operations appear + depends on the levels an admin set, so an install that has trusted nobody + with writes publishes a grant that cannot be read as offering them. + """ + grant = build_grant(request.app, await writes_are_enabled(db)) + log.info("resonance grant fetched: %d operation(s), %d writing", + len(grant["allow"]), sum(1 for a in grant["allow"] if a.get("writes"))) + return JSONResponse( + grant, + media_type="application/json", + headers={"Cache-Control": "public, max-age=300"}, + ) + + +@router.get(SPEC_PATH, include_in_schema=False) +async def resonance_spec(request: Request, db: aiosqlite.Connection = Depends(get_db)): + """The OpenAPI document for the granted operations.""" + allow_writes = await writes_are_enabled(db) + if allow_writes not in _spec_cache: + _spec_cache[allow_writes] = build_spec(request.app, allow_writes) + log.info("resonance spec fetched (writes %s)", "included" if allow_writes else "withheld") + return JSONResponse( + _spec_cache[allow_writes], + media_type="application/json", + headers={"Cache-Control": "public, max-age=300"}, + ) diff --git a/app/api/resonance_data/errors.py b/app/api/resonance_data/errors.py new file mode 100644 index 0000000..308771d --- /dev/null +++ b/app/api/resonance_data/errors.py @@ -0,0 +1,72 @@ +""" +The error type these endpoints raise and the one handler that turns it into the documented JSON body. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException, Request +from fastapi.exceptions import ResponseValidationError +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from app.api.resonance_data._common import log + + +# ── Errors ──────────────────────────────────────────────────────────────────── + + +class ResonanceDataError(HTTPException): + """Rendered as {"error": "..."} — the message reaches the person verbatim.""" + + +class ErrorResponse(BaseModel): + error: str = Field(description="What went wrong, phrased for the person to act on.") + + +def register_error_handler(app) -> None: + """Give this surface the {"error": ...} body the grant contract specifies. + + Scoped to ResonanceDataError so the rest of the app keeps FastAPI's + {"detail": ...}, which its own frontend already reads. + """ + + @app.exception_handler(ResonanceDataError) + async def _render(_request: Request, exc: ResonanceDataError): # noqa: ANN202 + return JSONResponse({"error": exc.detail}, status_code=exc.status_code) + + @app.exception_handler(ResponseValidationError) + async def _schema_drifted(request: Request, exc: ResponseValidationError): # noqa: ANN202 + """Report a declared schema that no longer matches what the tables return. + + This fires after the route body has already succeeded, so the module's + own try/except cannot see it, and it is logged by uvicorn rather than by + anything the SQLite handler is attached to — a 500 with a generic + message in the panel and not one line anywhere on the server. Now it + names the fields. + + Only this surface is rewritten; every other response_model in the app + keeps FastAPI's existing behaviour. + """ + if not request.url.path.startswith("/api/resonance/"): + raise exc + fields = sorted({".".join(str(p) for p in err.get("loc", ())[-2:]) + for err in exc.errors()})[:8] + log.error( + "resonance response schema no longer matches the data on %s: %s", + request.url.path, ", ".join(fields) or "unknown field", + ) + return JSONResponse( + {"error": "pktWiFi produced a result it could not describe. This is a fault in " + "pktWiFi, not in the question — it has been logged."}, + status_code=500, + ) + + +_ERRORS: dict[int | str, dict[str, Any]] = { + 401: {"model": ErrorResponse, "description": "No signed-in session on this request."}, + 403: {"model": ErrorResponse, "description": "Signed in, but not permitted to use the assistant."}, + 404: {"model": ErrorResponse, "description": "The assistant is switched off on this install."}, + 503: {"model": ErrorResponse, "description": "A backing store this operation needs is not available."}, + 504: {"model": ErrorResponse, "description": "The store did not answer in time; ask something narrower."}, +} diff --git a/app/api/resonance_data/models.py b/app/api/resonance_data/models.py new file mode 100644 index 0000000..eda5740 --- /dev/null +++ b/app/api/resonance_data/models.py @@ -0,0 +1,226 @@ +""" +The declared response shapes of the read operations. +""" +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class AccessPoint(BaseModel): + """One access point pktWiFi knows about.""" + + model_config = ConfigDict(extra="allow") + + id: int + name: Optional[str] = None + mac_address: Optional[str] = None + ip_address: Optional[str] = None + vendor: Optional[str] = None + model: Optional[str] = None + firmware_version: Optional[str] = None + site: Optional[str] = None + floor: Optional[str] = None + status: Optional[str] = Field(None, description="online, offline, or unknown.") + is_rogue: bool = Field(False, description="True when this AP is not one of ours.") + uptime_seconds: Optional[int] = None + client_count: int = Field(0, description="Clients currently associated across its radios.") + collector_id: Optional[int] = None + last_seen: Optional[str] = Field(None, description="When it was last polled (ISO 8601).") + + +class AccessPointList(BaseModel): + model_config = ConfigDict(extra="allow") + total: int = Field(description="How many access points matched, before paging.") + limit: int + offset: int + returned: int = 0 + truncated_for_size: bool = Field( + False, description="True when the page was cut to fit. Ask for fewer, or narrow the filters." + ) + access_points: list[AccessPoint] = Field(default_factory=list) + + +class WifiClient(BaseModel): + """One client currently associated, as last observed.""" + + model_config = ConfigDict(extra="allow") + + id: int + mac_address: Optional[str] = None + hostname: Optional[str] = None + ip_address: Optional[str] = None + ssid: Optional[str] = None + band: Optional[str] = Field(None, description="The band as the controller reports it: 2.4GHz, 5GHz or 6GHz.") + protocol: Optional[str] = Field(None, description="The 802.11 generation it associated with.") + rssi_dbm: Optional[int] = Field( + None, description="Signal strength in dBm. Closer to zero is stronger; below -70 is poor." + ) + snr_db: Optional[int] = Field(None, description="Signal-to-noise ratio in dB. Under 20 is poor.") + tx_rate_mbps: Optional[float] = None + rx_rate_mbps: Optional[float] = None + access_point_id: Optional[int] = None + access_point_name: Optional[str] = Field(None, description="Which AP it is on, for reading back.") + connected_at: Optional[str] = None + last_seen: Optional[str] = Field(None, description="When it was last observed (ISO 8601).") + + +class WifiClientList(BaseModel): + model_config = ConfigDict(extra="allow") + total: int + limit: int + offset: int + returned: int = 0 + truncated_for_size: bool = False + clients: list[WifiClient] = Field(default_factory=list) + + +class Radio(BaseModel): + """One radio on an access point — the thing a channel and a power belong to.""" + + model_config = ConfigDict(extra="allow") + + id: int + access_point_id: Optional[int] = None + access_point_name: Optional[str] = None + band: Optional[str] = Field(None, description="The band as the controller reports it: 2.4GHz, 5GHz or 6GHz.") + channel: Optional[int] = None + channel_width_mhz: Optional[int] = None + tx_power_dbm: Optional[int] = None + utilization_pct: Optional[float] = Field( + None, description="How busy the channel is. Sustained above 50 is congestion." + ) + noise_floor_dbm: Optional[int] = None + client_count: Optional[int] = None + updated_at: Optional[str] = None + + +class RadioList(BaseModel): + model_config = ConfigDict(extra="allow") + total: int + returned: int = 0 + truncated_for_size: bool = False + radios: list[Radio] = Field(default_factory=list) + + +class Collector(BaseModel): + """A collector pktWiFi polls a wireless controller through.""" + + model_config = ConfigDict(extra="allow") + + id: int + name: Optional[str] = None + collector_type: Optional[str] = Field(None, description="Which controller it speaks to.") + enabled: bool = False + status: Optional[str] = None + poll_interval_sec: Optional[int] = None + last_poll_at: Optional[str] = Field(None, description="When it last ran (ISO 8601).") + last_error: Optional[str] = Field(None, description="Why the last poll failed, if it did.") + + +class CollectorList(BaseModel): + model_config = ConfigDict(extra="allow") + total: int + returned: int = 0 + truncated_for_size: bool = False + collectors: list[Collector] = Field(default_factory=list) + + +class WifiSummary(BaseModel): + """Counts across the whole wireless estate — the "how are we doing" answer.""" + + model_config = ConfigDict(extra="allow") + + access_points: int + access_points_online: int + rogue_access_points: int = Field(description="APs seen that are not ours.") + clients: int = Field(description="Clients currently associated.") + radios: int + ssids: int + sites: int + collectors: int + collectors_enabled: int + unacknowledged_alerts: int + + +class AlertEvent(BaseModel): + """One firing of a pktWiFi alert rule.""" + + model_config = ConfigDict(extra="allow") + + id: int + rule_id: Optional[int] = None + rule_name: Optional[str] = Field(None, description="Name of the rule that fired.") + access_point_id: Optional[int] = Field(None, description="The AP it is about, if any.") + client_mac: Optional[str] = Field(None, description="The client it is about, if any.") + severity: Optional[str] = None + message: Optional[str] = None + value: Optional[float] = None + threshold: Optional[float] = None + active: bool = Field(False, description="True while the condition behind it still holds.") + acked: bool = False + acked_by: Optional[str] = None + acked_at: Optional[str] = None + resolved: bool = False + resolved_at: Optional[str] = None + created_at: Optional[str] = Field(None, description="When it fired (ISO 8601).") + + +class AlertEventList(BaseModel): + model_config = ConfigDict(extra="allow") + total: int + limit: int + offset: int + returned: int = 0 + truncated_for_size: bool = False + events: list[AlertEvent] = Field(default_factory=list) + + +class AlertRule(BaseModel): + model_config = ConfigDict(extra="allow") + id: int + name: Optional[str] = None + condition_type: Optional[str] = Field(None, description="What the rule watches.") + threshold: Optional[float] = None + severity: Optional[str] = None + enabled: bool = False + created_at: Optional[str] = None + channels: list[str] = Field( + default_factory=list, + description=( + "Which notification channels this rule sends on when it fires. Empty means it records " + "the firing here and notifies nobody — an enabled rule with no channels is being " + "watched but not reported." + ), + ) + + +class AlertRuleList(BaseModel): + model_config = ConfigDict(extra="allow") + total: int + returned: int = 0 + truncated_for_size: bool = False + rules: list[AlertRule] = Field(default_factory=list) + + +class AppLogRecord(BaseModel): + """One line of pktWiFi's own diagnostic log — not wireless data.""" + + model_config = ConfigDict(extra="allow") + + id: int + level: Optional[str] = None + logger: Optional[str] = Field(None, description="Which part of pktWiFi wrote it.") + message: Optional[str] = None + created_at: Optional[str] = Field(None, description="When it was written (ISO 8601).") + + +class AppLogResult(BaseModel): + model_config = ConfigDict(extra="allow") + total: int + limit: int + offset: int + returned: int = 0 + truncated_for_size: bool = False + records: list[AppLogRecord] = Field(default_factory=list) diff --git a/app/api/resonance_data/read.py b/app/api/resonance_data/read.py new file mode 100644 index 0000000..4adc5ee --- /dev/null +++ b/app/api/resonance_data/read.py @@ -0,0 +1,566 @@ +""" +The read operations, and the budget that keeps every answer small and quick. +""" +from __future__ import annotations + +import asyncio +import json +from typing import Any, Optional + +import aiosqlite +from fastapi import Depends, Path, Query + +from app.api.resonance_data._common import AlertSeverity, ApStatus, Band, DATA_PREFIX, LogLevel, router +from app.api.resonance_data.errors import ErrorResponse, ResonanceDataError, _ERRORS +from app.api.resonance_data.models import AccessPoint, AccessPointList, AlertEventList, AlertRuleList, AppLogResult, CollectorList, RadioList, WifiClientList, WifiSummary +from app.api.resonance_data.session import SessionUser +from app.database import get_db +from app.sqlutil import Where +from app.wifi.freshness import client_is_current, radio_is_current + + +# ── Operations ──────────────────────────────────────────────────────────────── +# +# Every summary and description here is written for a reader who has never seen +# pktWiFi, because that is literally what chooses between them: a model picks an +# operation from these sentences and nothing else. "Search logs" would leave it +# guessing between the certificate inventory and the app's own diagnostics, +# which are two entirely different questions asked with almost the same words. + +# One page is capped well below what the SPA allows. The panel's results are +# read back to a person in a conversation, so a hundred rows is already past the +# point of being an answer, and a model handed five hundred narrows nothing. The +# maxima are deliberately above what always fits — _fit() reports the cut, and a +# caller that wants density should be able to ask for it. +_SEARCH_DEFAULT, _SEARCH_MAX = 25, 100 + + +_LIST_DEFAULT, _LIST_MAX = 50, 200 + + +# Resonance truncates a result over 20 KB and tells the model it did. That turns +# a clean page into JSON that stops mid-record, so the cut is made here instead, +# where it can leave the envelope intact and say what happened in a field the +# model can act on. 18 KB leaves headroom for transport framing. +_RESULT_BUDGET_BYTES = 18_000 + + +# Resonance gives up on a call after 20 seconds and tells the person the +# application did not answer. Answering at 15 with something they can act on +# beats going quiet at 20. +_CALL_TIMEOUT_SECONDS = 15 + + +def _encoded_size(value: Any) -> int: + return len(json.dumps(value, default=str).encode("utf-8")) + + +def _fit(payload: dict, items_key: str) -> dict: + """Trim a page to the byte budget, and record that it had to. + + Always keeps at least one item: an empty page for one oversized record is a + worse answer than an oversized one, and the caller can still see `total`. + """ + items = list(payload.get(items_key) or []) + # Price the envelope with the two fields this adds, so adding them cannot + # push a result that just fitted back over the line. + envelope = dict(payload) + envelope[items_key] = [] + envelope["returned"] = len(items) + envelope["truncated_for_size"] = True + budget = _RESULT_BUDGET_BYTES - _encoded_size(envelope) + + kept: list = [] + used = 0 + for item in items: + size = _encoded_size(item) + 1 # + the separating comma + if kept and used + size > budget: + break + kept.append(item) + used += size + + payload[items_key] = kept + payload["returned"] = len(kept) + payload["truncated_for_size"] = len(kept) < len(items) + return payload + + +async def _in_time(awaitable, what: str): + """Bound a query so a slow one is answered rather than abandoned.""" + try: + return await asyncio.wait_for(awaitable, _CALL_TIMEOUT_SECONDS) + except asyncio.TimeoutError as exc: + raise ResonanceDataError( + status_code=504, + detail=( + f"pktWiFi took longer than {_CALL_TIMEOUT_SECONDS} seconds to {what}. " + "Narrow the time range, or filter by status, CA or name." + ), + ) from exc + + +@router.get( + f"{DATA_PREFIX}/summary", + operation_id="getWifiSummary", + summary="Counts across the whole wireless estate", + description=( + "One small result answering 'how are we doing' — how many access points exist and how " + "many are online, how many rogues have been seen, how many clients are associated, how " + "many radios, SSIDs and sites are configured, how many collectors are enabled, and how " + "many alerts are outstanding. Ask this before listAccessPoints when the question is " + "about totals rather than about particular access points." + ), + response_model=WifiSummary, + responses=_ERRORS, +) +async def get_wifi_summary( + _user: dict = SessionUser, + db: aiosqlite.Connection = Depends(get_db), +): + async def _count(query: str) -> int: + async with db.execute(query) as cur: + row = await cur.fetchone() + return row[0] if row else 0 + + return { + "access_points": await _count("SELECT COUNT(*) FROM access_points"), + "access_points_online": await _count( + "SELECT COUNT(*) FROM access_points WHERE status = 'online'" + ), + "rogue_access_points": await _count( + "SELECT COUNT(*) FROM access_points WHERE is_rogue = 1" + ), + "clients": await _count( + f"SELECT COUNT(*) FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id " + f"WHERE {client_is_current()}" + ), + "radios": await _count( + f"SELECT COUNT(*) FROM radios r JOIN access_points ap ON ap.id = r.access_point_id " + f"WHERE {radio_is_current()}" + ), + "ssids": await _count("SELECT COUNT(*) FROM ssids"), + "sites": await _count("SELECT COUNT(*) FROM sites"), + "collectors": await _count("SELECT COUNT(*) FROM collectors"), + "collectors_enabled": await _count("SELECT COUNT(*) FROM collectors WHERE enabled = 1"), + "unacknowledged_alerts": await _count("SELECT COUNT(*) FROM alert_events WHERE acked = 0"), + } + + +@router.get( + f"{DATA_PREFIX}/access-points", + operation_id="listAccessPoints", + summary="List the access points", + description=( + "The access points pktWiFi knows about — where each is, what it is, whether it is " + "answering, and how many clients are on it. Set rogue_only for access points that are " + "not ours, which is a security question rather than a capacity one. Every filter is " + "optional and they combine with AND. Returns at most `limit` access points plus the " + "total that matched." + ), + response_model=AccessPointList, + responses=_ERRORS, +) +async def list_access_points( + _user: dict = SessionUser, + status: Optional[ApStatus] = Query(None, description="Only access points in this state."), + site: Optional[str] = Query(None, max_length=120, description="Only access points at this site."), + rogue_only: bool = Query(False, description="Only access points flagged as not ours."), + search: Optional[str] = Query( + None, max_length=200, description="Substring of the name, MAC, address or model." + ), + limit: int = Query( + _SEARCH_DEFAULT, ge=1, le=_SEARCH_MAX, + description=f"How many to return. Default {_SEARCH_DEFAULT}, maximum {_SEARCH_MAX}.", + ), + offset: int = Query(0, ge=0, description="How many to skip, for paging."), + db: aiosqlite.Connection = Depends(get_db), +): + w = Where() + if status: + w.add("a.status = ?", status) + if site: + w.add("a.site = ?", site) + if rogue_only: + w.add("a.is_rogue = 1") + if search: + like = f"%{search}%" + w.add("(a.name LIKE ? OR a.mac_address LIKE ? OR a.ip_address LIKE ? OR a.model LIKE ?)", *([like] * 4)) + where = w.sql + + async with db.execute(f"SELECT COUNT(*) FROM access_points a {where}", w.params) as cur: + total = (await cur.fetchone())[0] + + async with db.execute( + f"""SELECT a.id, a.name, a.mac_address, a.ip_address, a.vendor, a.model, + a.firmware_version, a.site, a.floor, a.status, a.is_rogue, + a.uptime_seconds, a.collector_id, a.last_seen, + (SELECT COUNT(*) FROM wifi_clients c WHERE c.access_point_id = a.id AND {client_is_current(ap='a')}) + AS client_count + FROM access_points a + {where} + ORDER BY a.name + LIMIT ? OFFSET ?""", + w.with_params(limit, offset), + ) as cur: + rows = await cur.fetchall() + + aps = [] + for r in rows: + d = dict(r) + d["is_rogue"] = bool(d.get("is_rogue")) + aps.append(d) + + return _fit( + {"total": total, "limit": limit, "offset": offset, "access_points": aps}, + "access_points", + ) + + +@router.get( + f"{DATA_PREFIX}/access-points/{{ap_id}}", + operation_id="getAccessPoint", + summary="Read one access point in full", + description=( + "Everything pktWiFi records about a single access point, by the id listAccessPoints " + "returned. Use this after a search when the question is about one AP in particular — " + "its firmware, its uptime, how many clients it is carrying." + ), + response_model=AccessPoint, + responses={**_ERRORS, 404: {"model": ErrorResponse, "description": "No access point with that id."}}, +) +async def get_access_point( + ap_id: int = Path(description="Id of the access point, as returned by listAccessPoints."), + _user: dict = SessionUser, + db: aiosqlite.Connection = Depends(get_db), +): + async with db.execute( + f"""SELECT a.id, a.name, a.mac_address, a.ip_address, a.vendor, a.model, + a.firmware_version, a.site, a.floor, a.status, a.is_rogue, + a.uptime_seconds, a.collector_id, a.last_seen, + (SELECT COUNT(*) FROM wifi_clients c WHERE c.access_point_id = a.id AND {client_is_current(ap='a')}) + AS client_count + FROM access_points a WHERE a.id = ?""", + (ap_id,), + ) as cur: + row = await cur.fetchone() + if not row: + raise ResonanceDataError(status_code=404, detail=f"There is no access point {ap_id}.") + d = dict(row) + d["is_rogue"] = bool(d.get("is_rogue")) + return d + + +@router.get( + f"{DATA_PREFIX}/clients", + operation_id="listWifiClients", + summary="List associated wireless clients", + description=( + "The clients pktWiFi last saw associated, with the access point and SSID each is on and " + "how good their radio link is. This is the 'why is that laptop slow on wifi' answer: " + "rssi_dbm closer to zero is stronger and below -70 is poor, snr_db under 20 is poor. " + "Weakest signal first when no other order is implied. Every filter is optional." + ), + response_model=WifiClientList, + responses=_ERRORS, +) +async def list_wifi_clients( + _user: dict = SessionUser, + access_point_id: Optional[int] = Query(None, description="Only clients on this access point."), + ssid: Optional[str] = Query(None, max_length=120, description="Only clients on this SSID."), + band: Optional[Band] = Query(None, description="Only clients on this band, in GHz."), + weak_signal_only: bool = Query( + False, description="Only clients at or below -70 dBm — the ones actually having a bad time." + ), + search: Optional[str] = Query( + None, max_length=200, description="Substring of the MAC, hostname or address." + ), + limit: int = Query( + _SEARCH_DEFAULT, ge=1, le=_SEARCH_MAX, + description=f"How many to return. Default {_SEARCH_DEFAULT}, maximum {_SEARCH_MAX}.", + ), + offset: int = Query(0, ge=0, description="How many to skip, for paging."), + db: aiosqlite.Connection = Depends(get_db), +): + w = Where() + if access_point_id is not None: + w.add("c.access_point_id = ?", access_point_id) + if ssid: + w.add("c.ssid = ?", ssid) + if band: + # The filter says 5, the table stores 5GHz. + w.add("c.band = ?", f"{band}GHz") + if weak_signal_only: + w.add("c.rssi_dbm IS NOT NULL AND c.rssi_dbm <= -70") + if search: + like = f"%{search}%" + w.add("(c.mac_address LIKE ? OR c.hostname LIKE ? OR c.ip_address LIKE ?)", *([like] * 3)) + # Only clients on air now; the tables keep rows a poll no longer reports. + w.add(client_is_current(ap="a")) + where = w.sql + + async with db.execute( + f"SELECT COUNT(*) FROM wifi_clients c JOIN access_points a ON a.id = c.access_point_id {where}", + w.params, + ) as cur: + total = (await cur.fetchone())[0] + + async with db.execute( + f"""SELECT c.id, c.mac_address, c.hostname, c.ip_address, c.ssid, c.band, + c.protocol, c.rssi_dbm, c.snr_db, c.tx_rate_mbps, c.rx_rate_mbps, + c.access_point_id, c.connected_at, c.last_seen, + a.name AS access_point_name + FROM wifi_clients c + JOIN access_points a ON a.id = c.access_point_id + {where} + ORDER BY c.rssi_dbm ASC + LIMIT ? OFFSET ?""", + w.with_params(limit, offset), + ) as cur: + rows = await cur.fetchall() + + return _fit( + {"total": total, "limit": limit, "offset": offset, "clients": [dict(r) for r in rows]}, + "clients", + ) + + +@router.get( + f"{DATA_PREFIX}/radios", + operation_id="listRadios", + summary="List access-point radios, with channel and congestion", + description=( + "One row per radio: its band, channel, width, transmit power, how busy the channel is " + "and what the noise floor looks like. This is the 'is the 2.4 band congested' and 'what " + "channel is that AP on' answer — utilization_pct sustained above 50 is congestion. " + "Busiest first." + ), + response_model=RadioList, + responses=_ERRORS, +) +async def list_radios( + _user: dict = SessionUser, + access_point_id: Optional[int] = Query(None, description="Only radios on this access point."), + band: Optional[Band] = Query(None, description="Only radios on this band, in GHz."), + db: aiosqlite.Connection = Depends(get_db), +): + w = Where() + if access_point_id is not None: + w.add("r.access_point_id = ?", access_point_id) + if band: + w.add("r.band = ?", f"{band}GHz") + w.add(radio_is_current(ap="a")) + where = w.sql + + async with db.execute( + f"""SELECT r.id, r.access_point_id, r.band, r.channel, r.channel_width_mhz, + r.tx_power_dbm, r.utilization_pct, r.noise_floor_dbm, r.client_count, + r.updated_at, a.name AS access_point_name + FROM radios r + JOIN access_points a ON a.id = r.access_point_id + {where} + ORDER BY r.utilization_pct DESC""", + w.params, + ) as cur: + rows = await cur.fetchall() + radios = [dict(r) for r in rows] + return _fit({"total": len(radios), "radios": radios}, "radios") + + +@router.get( + f"{DATA_PREFIX}/collectors", + operation_id="listCollectors", + summary="List the collectors pktWiFi polls", + description=( + "Where pktWiFi's data comes from — which controller each collector talks to, whether it " + "is enabled, when it last ran and why it failed if it did. A collector that stopped " + "polling explains every access point going quiet at once, so read this before " + "concluding the wireless itself is down. No controller credential is returned." + ), + response_model=CollectorList, + responses=_ERRORS, +) +async def list_collectors( + _user: dict = SessionUser, + db: aiosqlite.Connection = Depends(get_db), +): + # config_json holds the controller credentials and is deliberately not selected. + async with db.execute( + "SELECT id, name, collector_type, enabled, status, poll_interval_sec, " + "last_poll_at, last_error FROM collectors ORDER BY name" + ) as cur: + rows = await cur.fetchall() + collectors = [] + for r in rows: + d = dict(r) + d["enabled"] = bool(d.get("enabled")) + collectors.append(d) + return _fit({"total": len(collectors), "collectors": collectors}, "collectors") + + +@router.get( + f"{DATA_PREFIX}/alerts/events", + operation_id="listAlertEvents", + summary="List alerts that have fired", + description=( + "Individual firings of pktWiFi's alert rules — an access point going offline, a rogue " + "appearing, a channel saturating — newest first. This is what to read for 'what is " + "wrong' or 'what happened overnight'. An event with acked false is one nobody has looked " + "at yet; active true means the condition still holds." + ), + response_model=AlertEventList, + responses=_ERRORS, +) +async def list_alert_events( + _user: dict = SessionUser, + unacked_only: bool = Query(False, description="Only events nobody has acknowledged yet."), + active_only: bool = Query(False, description="Only events whose condition still holds."), + severity: Optional[AlertSeverity] = Query(None, description="Only events raised at this severity."), + access_point_id: Optional[int] = Query(None, description="Only events about this access point."), + since: Optional[str] = Query(None, description="Only events fired at or after this time. ISO 8601."), + until: Optional[str] = Query(None, description="Only events fired at or before this time. ISO 8601."), + limit: int = Query( + _SEARCH_DEFAULT, ge=1, le=_SEARCH_MAX, + description=f"How many to return. Default {_SEARCH_DEFAULT}, maximum {_SEARCH_MAX}.", + ), + offset: int = Query(0, ge=0, description="How many to skip, for paging."), + db: aiosqlite.Connection = Depends(get_db), +): + w = Where() + if unacked_only: + w.add("e.acked = 0") + if active_only: + w.add("e.active = 1") + if severity: + w.add("e.severity = ?", severity) + if access_point_id is not None: + w.add("e.access_point_id = ?", access_point_id) + if since: + # created_at is written by SQLite's datetime('now') — space separated, + # no 'Z' — so both sides go through datetime() to compare like for like. + w.add("e.created_at >= datetime(?)", since) + if until: + w.add("e.created_at <= datetime(?)", until) + where = w.sql + + async with db.execute(f"SELECT COUNT(*) FROM alert_events e {where}", w.params) as cur: + total = (await cur.fetchone())[0] + + async with db.execute( + f"""SELECT e.id, e.rule_id, e.access_point_id, e.client_mac, e.severity, e.message, + e.value, e.threshold, e.active, e.acked, e.acked_by, e.acked_at, + e.resolved, e.resolved_at, e.created_at, r.name AS rule_name + FROM alert_events e + LEFT JOIN alert_rules r ON r.id = e.rule_id + {where} + ORDER BY e.created_at DESC + LIMIT ? OFFSET ?""", + w.with_params(limit, offset), + ) as cur: + rows = await cur.fetchall() + + events = [] + for r in rows: + d = dict(r) + for flag in ("active", "acked", "resolved"): + d[flag] = bool(d.get(flag)) + events.append(d) + + return _fit({"total": total, "limit": limit, "offset": offset, "events": events}, "events") + + +@router.get( + f"{DATA_PREFIX}/alerts/rules", + operation_id="listAlertRules", + summary="List the configured alert rules", + description=( + "The rules an administrator has set up, whether each is switched on, what it watches and " + "at what threshold. Rules are the configuration; listAlertEvents is what they have " + "actually fired. Read this to answer 'are we even watching for that'. Switching a rule " + "is not something the assistant can do in pktWiFi — that is an administrator's to do in " + "the interface." + ), + response_model=AlertRuleList, + responses=_ERRORS, +) +async def list_alert_rules( + _user: dict = SessionUser, + enabled_only: bool = Query(False, description="Only rules that are currently switched on."), + db: aiosqlite.Connection = Depends(get_db), +): + where = "WHERE enabled = 1" if enabled_only else "" + async with db.execute( + f"SELECT id, name, condition_type, threshold, severity, enabled, created_at, channels " + f"FROM alert_rules {where} ORDER BY name" + ) as cur: + rows = await cur.fetchall() + rules = [] + for r in rows: + d = dict(r) + d["enabled"] = bool(d.get("enabled")) + # Stored as a JSON array; hand it over decoded rather than as a string + # the reader would have to parse itself. + try: + parsed = json.loads(d.get("channels") or "[]") + except (ValueError, TypeError): + parsed = [] + d["channels"] = parsed if isinstance(parsed, list) else [] + rules.append(d) + return _fit({"total": len(rules), "rules": rules}, "rules") + + +@router.get( + f"{DATA_PREFIX}/app-log", + operation_id="searchApplicationLog", + summary="Search pktWiFi's own diagnostic log", + description=( + "pktWiFi's internal log — what the application itself did and any errors it hit. This is " + "NOT wireless data: for access points use listAccessPoints, for clients use " + "listWifiClients, and for alert firings use listAlertEvents. Read this to answer 'why " + "did the controller poll stop'. Newest first." + ), + response_model=AppLogResult, + responses=_ERRORS, +) +async def search_application_log( + _user: dict = SessionUser, + level: Optional[LogLevel] = Query(None, description="Only lines at this level."), + logger: Optional[str] = Query( + None, max_length=120, description="Only lines from loggers with this prefix." + ), + search: Optional[str] = Query(None, max_length=200, description="Substring of the message."), + since: Optional[str] = Query(None, description="Only lines at or after this time. ISO 8601."), + until: Optional[str] = Query(None, description="Only lines at or before this time. ISO 8601."), + limit: int = Query( + _SEARCH_DEFAULT, ge=1, le=_SEARCH_MAX, + description=f"How many to return. Default {_SEARCH_DEFAULT}, maximum {_SEARCH_MAX}.", + ), + offset: int = Query(0, ge=0, description="How many to skip, for paging."), + db: aiosqlite.Connection = Depends(get_db), +): + w = Where() + if level: + w.add("level = ?", level) + if logger: + w.add("logger LIKE ?", f"{logger}%") + if search: + w.add("message LIKE ?", f"%{search}%") + if since: + w.add("created_at >= ?", since) + if until: + w.add("created_at <= ?", until) + where = w.sql + + async with db.execute(f"SELECT COUNT(*) FROM app_logs {where}", w.params) as cur: + total = (await cur.fetchone())[0] + + async with db.execute( + f"SELECT id, level, logger, message, created_at FROM app_logs {where} " + "ORDER BY id DESC LIMIT ? OFFSET ?", + w.with_params(limit, offset), + ) as cur: + rows = await cur.fetchall() + + return _fit( + {"total": total, "limit": limit, "offset": offset, "records": [dict(r) for r in rows]}, + "records", + ) diff --git a/app/api/resonance_data/session.py b/app/api/resonance_data/session.py new file mode 100644 index 0000000..d3168cf --- /dev/null +++ b/app/api/resonance_data/session.py @@ -0,0 +1,101 @@ +""" +Who may call these endpoints: the signed-in session, admitted by the same helpers as the panel's own code route. +""" +from __future__ import annotations + +import aiosqlite +from fastapi import Depends, HTTPException, Request + +from app.api.resonance import LEVEL_RANK, _allowed_roles, _get, _same_origin, _user_for_code, role_level +from app.api.resonance_data._common import log +from app.api.resonance_data.errors import ResonanceDataError +from app.database import get_db + + +# ── Session ─────────────────────────────────────────────────────────────────── + + +async def resonance_session_user( + request: Request, db: aiosqlite.Connection = Depends(get_db) +) -> dict: + """Admit a call the panel made from our own page, on this app's own session. + + Same four gates as /api/resonance/code, in the same order and for the same + reasons: the request must present as same-origin before any cookie is + honoured, it must carry a session we recognise, the feature must be on, and + the person's role must be one an admin listed. The last two mean this whole + surface is inert on an install that never enabled the panel — a route that + exists but answers 404 until someone turns the feature on deliberately. + """ + if not _same_origin(request): + raise ResonanceDataError(status_code=403, detail="Cross-site request refused.") + + user = await _user_for_code(request, db) + if not user: + raise ResonanceDataError(status_code=401, detail="Not signed in to pktWiFi.") + + if not bool(await _get(db, "resonance_enabled", False)): + raise ResonanceDataError(status_code=404, detail="The assistant is not enabled on this install.") + + if user["role"] not in await _allowed_roles(db): + raise ResonanceDataError( + status_code=403, detail="Your role is not permitted to use the assistant." + ) + + # Audit trail, and the only way to answer "did the assistant actually ask us + # anything". A successful read is otherwise silent, so without this the + # difference between "the panel never called" and "the panel called and got + # what it wanted" is invisible from the server — which is exactly the + # question asked when an answer looks wrong. One line per call, at INFO, so + # it lands in the Logs page too. + route = request.scope.get("route") + log.info( + "resonance call: %s (%s) -> %s", + user.get("username"), user.get("role"), + getattr(route, "operation_id", None) or request.url.path, + ) + return user + + +async def resonance_write_user( + request: Request, db: aiosqlite.Connection = Depends(get_db) +) -> dict: + """As above, and the role must be set to "write" rather than "read". + + Two gates have to agree before anything changes, and they answer different + questions. This one is the admin's: has this role been trusted to let the + assistant act at all. The second, inside each operation, is pktWiFi's own: + may this person do this thing anyway. A role set to "write" never gains a + right its holder does not already have in the interface — it only decides + whether the assistant may exercise the rights they do have. + """ + user = await resonance_session_user(request, db) + if LEVEL_RANK.get(await role_level(db, user["role"]), 0) < LEVEL_RANK["write"]: + raise ResonanceDataError( + status_code=403, + detail=("The assistant is set to read-only for your role, so it cannot make " + "that change. An administrator sets this under Settings → Resonance."), + ) + return user + + +async def _apply_app_rule(user: dict, rule, what: str) -> None: + """Apply pktWiFi's own role rule for the endpoint this operation mirrors. + + The rule itself is imported rather than restated, so a change to who may do + something in the interface reaches the assistant in the same commit instead + of leaving two role models to drift apart. + """ + try: + await rule(user) + except HTTPException as exc: + raise ResonanceDataError( + status_code=exc.status_code, + detail=f"Your pktWiFi role does not permit you to {what}.", + ) from exc + + +SessionUser = Depends(resonance_session_user) + + +WriteUser = Depends(resonance_write_user) diff --git a/app/api/resonance_data/writes.py b/app/api/resonance_data/writes.py new file mode 100644 index 0000000..224549a --- /dev/null +++ b/app/api/resonance_data/writes.py @@ -0,0 +1,146 @@ +""" +The operations that change something: acknowledging alerts. +""" +from __future__ import annotations + +from typing import Optional + +import aiosqlite +from fastapi import Depends, Path +from pydantic import BaseModel, ConfigDict, Field + +from app.api.resonance_data._common import DATA_PREFIX, log, router +from app.api.resonance_data.errors import ErrorResponse, ResonanceDataError, _ERRORS +from app.api.resonance_data.session import WriteUser, _apply_app_rule +from app.database import get_db +from app.dependencies import require_analyst + + +# ── Operations that change something ────────────────────────────────────────── +# +# Every one of these is marked `writes: true` in the grant, so resonance stops +# and reads the actual values back to the person before it runs one. That +# confirmation is theirs to enforce and cannot be relied on here, which is why +# both gates above still apply on the request itself. +# +# What is deliberately absent is as much of the design as what is present: no +# delete of anything, no clearing of logs, and no creating or editing of +# configuration. An assistant can act on what an administrator already put +# there, and cannot author or destroy it. + + +class AckResult(BaseModel): + model_config = ConfigDict(extra="allow") + event_id: int = Field(description="The alert event this refers to.") + acknowledged: bool = Field(description="True if this call acknowledged it.") + already_acknowledged: bool = Field( + description="True when someone had already acknowledged it, in which case nothing changed." + ) + acked_at: Optional[str] = Field(None, description="When it was acknowledged (ISO 8601, UTC).") + message: str = Field(description="What happened, phrased to be read back to the person.") + + +class AckAllResult(BaseModel): + model_config = ConfigDict(extra="allow") + acknowledged: int = Field(description="How many outstanding alerts this call acknowledged.") + message: str = Field(description="What happened, phrased to be read back to the person.") + + +@router.post( + f"{DATA_PREFIX}/alerts/events/{{event_id}}/ack", + operation_id="ackAlertEvent", + summary="Acknowledge one alert", + description=( + "Mark a single fired alert as seen, recording who did it and when. This changes state. It " + "does not resolve the alert or fix the condition behind it — a certificate close to " + "expiry is still close to expiry, and the rule will fire again. Acknowledging something " + "already acknowledged changes nothing and says so. Available to analysts and " + "administrators, as in the interface." + ), + response_model=AckResult, + responses={**_ERRORS, 404: {"model": ErrorResponse, "description": "No alert event with that id."}}, +) +async def ack_alert_event( + event_id: int = Path( + description="Id of the alert event to acknowledge, as returned by listAlertEvents." + ), + user: dict = WriteUser, + db: aiosqlite.Connection = Depends(get_db), +): + await _apply_app_rule(user, require_analyst, "acknowledge alerts") + + async with db.execute( + "SELECT e.acked, e.acked_at, r.name FROM alert_events e " + "LEFT JOIN alert_rules r ON r.id = e.rule_id WHERE e.id = ?", + (event_id,), + ) as cur: + row = await cur.fetchone() + if not row: + raise ResonanceDataError(status_code=404, detail=f"There is no alert event {event_id}.") + + name = row["name"] or "unnamed rule" + if row["acked"]: + when = str(row["acked_at"] or "").replace(" ", "T") + "Z" if row["acked_at"] else None + return { + "event_id": event_id, "acknowledged": False, "already_acknowledged": True, + "acked_at": when, + "message": f"Alert {event_id} ({name}) was already acknowledged" + + (f" at {when}." if when else "."), + } + + await db.execute( + "UPDATE alert_events SET acked = 1, acked_by = ?, acked_at = datetime('now') " + "WHERE id = ? AND acked = 0", + (user.get("username"), event_id), + ) + await db.commit() + + async with db.execute("SELECT acked_at FROM alert_events WHERE id = ?", (event_id,)) as cur: + acked = (await cur.fetchone())["acked_at"] + when = str(acked).replace(" ", "T") + "Z" if acked else None + log.info("resonance: %s acknowledged alert event %s", user.get("username"), event_id) + return { + "event_id": event_id, "acknowledged": True, "already_acknowledged": False, + "acked_at": when, + "message": f"Acknowledged alert {event_id} ({name}). The condition behind it is unchanged.", + } + + +@router.post( + f"{DATA_PREFIX}/alerts/events/ack-all", + operation_id="ackAllAlertEvents", + summary="Acknowledge every outstanding alert", + description=( + "Mark every alert nobody has acknowledged yet as seen, in one go. This changes state, and " + "it is not reversible from here — there is no un-acknowledge. It resolves nothing: every " + "condition behind every alert is untouched. Reports how many were acknowledged, which is " + "zero when there was nothing outstanding. Available to analysts and administrators, as in " + "the interface." + ), + response_model=AckAllResult, + responses=_ERRORS, +) +async def ack_all_alert_events( + user: dict = WriteUser, + db: aiosqlite.Connection = Depends(get_db), +): + await _apply_app_rule(user, require_analyst, "acknowledge alerts") + + async with db.execute("SELECT COUNT(*) FROM alert_events WHERE acked = 0") as cur: + outstanding = (await cur.fetchone())[0] + if not outstanding: + return {"acknowledged": 0, "message": "There were no unacknowledged alerts."} + + await db.execute( + "UPDATE alert_events SET acked = 1, acked_by = ?, acked_at = datetime('now') " + "WHERE acked = 0", + (user.get("username"),), + ) + await db.commit() + log.info("resonance: %s acknowledged all %d outstanding alerts", + user.get("username"), outstanding) + return { + "acknowledged": outstanding, + "message": f"Acknowledged {outstanding} alert" + f"{'' if outstanding == 1 else 's'}. None of the conditions behind them changed.", + } diff --git a/app/api/settings.py b/app/api/settings.py index 8a6a444..bfbcc3e 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -8,14 +8,16 @@ """ from __future__ import annotations +import asyncio import json from typing import Any import aiosqlite from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.alerts import notify +from app.auth import saml as saml_auth from app.database import get_db from app.dependencies import CurrentUser, AdminUser @@ -94,6 +96,14 @@ class TestNotificationRequest(BaseModel): channel: str +# An IdP's metadata is a few kilobytes; a federation's can be a few hundred. +_MAX_METADATA_CHARS = 512_000 + + +class ParseMetadataRequest(BaseModel): + xml: str = Field(min_length=1, max_length=_MAX_METADATA_CHARS) + + @router.get("") async def get_settings(user: CurrentUser, db: aiosqlite.Connection = Depends(get_db)): async with db.execute("SELECT key, value FROM settings") as cur: @@ -157,3 +167,13 @@ async def test_notification( ) result = await notify.send(db, body.channel, alert) return {"status": result.status, "detail": result.detail} + + +@router.post("/saml/parse-metadata") +async def parse_saml_metadata(body: ParseMetadataRequest, _: AdminUser): + """Read an IdP's metadata XML and return the three fields the Auth tab fills + in from it. Nothing is stored — the person reviews them and saves.""" + try: + return await asyncio.to_thread(saml_auth.parse_idp_metadata, body.xml) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) diff --git a/app/api/suite.py b/app/api/suite.py index 593dad0..aa76c03 100644 --- a/app/api/suite.py +++ b/app/api/suite.py @@ -48,7 +48,9 @@ async def get_suite_token(request: Request, user: AdminUser): invalidate_settings_cache() token = new_token except Exception: - pass + # The caller is told there is no token rather than that this failed, + # so the reason has to be in the log. + log.exception("could not store a generated suite token") return JSONResponse({"suite_token": token, "has_token": bool(token)}) @@ -80,8 +82,10 @@ async def suite_register(request: Request, user: AdminUser): await db.commit() invalidate_settings_cache() return JSONResponse({"status": "ok"}) - except Exception as exc: - return JSONResponse({"error": str(exc)}, status_code=500) + except Exception: + # The exception text carries the database path and internal SQL — log it, don't return it. + log.exception("suite endpoint failed") + return JSONResponse({"error": "Internal error"}, status_code=500) @router.post("/regenerate") @@ -104,8 +108,10 @@ async def regenerate_suite_token(request: Request, user: AdminUser): await db.commit() invalidate_settings_cache() return JSONResponse({"suite_token": new_token, "status": "regenerated"}) - except Exception as exc: - return JSONResponse({"error": str(exc)}, status_code=500) + except Exception: + # The exception text carries the database path and internal SQL — log it, don't return it. + log.exception("suite endpoint failed") + return JSONResponse({"error": "Internal error"}, status_code=500) @router.post("/settings-lock") @@ -141,8 +147,10 @@ async def set_settings_lock(request: Request, user: CurrentUser): (json.dumps(locked),) ) await db.commit() - except Exception as exc: - return JSONResponse({"error": str(exc)}, status_code=500) + except Exception: + # The exception text carries the database path and internal SQL — log it, don't return it. + log.exception("suite endpoint failed") + return JSONResponse({"error": "Internal error"}, status_code=500) return JSONResponse({"hub_settings_managed": locked}) diff --git a/app/api/system.py b/app/api/system.py index 64d6909..0373691 100644 --- a/app/api/system.py +++ b/app/api/system.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +import logging import os import re import shutil @@ -25,6 +26,8 @@ from app.backup import run_backup_sync, list_backups_sync, _read_backup_settings_sync from app.version import get_version +log = logging.getLogger("pktwifi.api.system") + router = APIRouter() @@ -142,8 +145,10 @@ def _cert_info() -> dict: except Exception: pass return info - except Exception as e: - return {"error": str(e)} + except Exception: + # The text can name the certificate path and openssl's own output. + log.exception("could not read the TLS certificate") + return {"error": "Could not read the certificate"} @router.get("/ssl/status") @@ -425,8 +430,9 @@ def _do_restore(raw: bytes) -> dict: try: with tarfile.open(str(archive_path), "r:gz") as tar: _safe_extract_tar(tar, tmp_path) - except Exception as e: - return {"error": f"Failed to extract archive: {e}"} + except Exception: + log.exception("restore: could not extract the uploaded archive") + return {"error": "Failed to extract archive — is it a pktWiFi backup (.tar.gz)?"} return _restore_from_dir(tmp_path, settings, wanted) diff --git a/app/api/widgets.py b/app/api/widgets.py deleted file mode 100644 index 9ae1d26..0000000 --- a/app/api/widgets.py +++ /dev/null @@ -1,1053 +0,0 @@ -""" -pktWiFi — Widget endpoints for pktHub NOC Builder integration. - -Manifest: GET /api/widgets/manifest → list of widget definitions -Views: GET /api/widgets/{id} → server-rendered HTML page (iframe target) -Options: GET /api/widgets/options/* → JSON [{value,label}] for dynamic param pickers -""" -from __future__ import annotations - -import html -from contextvars import ContextVar -from datetime import datetime, timedelta, timezone - -import aiosqlite -from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse, JSONResponse - -from app.config import get_settings -from app.dependencies import require_suite_token -from app.wifi.freshness import client_is_current, radio_is_current - -# These views are embedded as unauthenticated iframes by pktHub's NOC Builder, -# so they can't require a login session — but they do render internal access -# point, client and alert data, so every route on this router requires a valid -# X-Suite-Token (the trusted-proxy secret pktHub already sends on every -# proxied request). -# ── Refresh interval ────────────────────────────────────────────────────────── -# pktHub's Settings → NOC → "Widget refresh" governs how often a tile reloads -# itself. It arrives as ?refresh= on the widget URL; captured here as a -# router dependency so the ~150 view functions need no signature change. -_REFRESH: ContextVar = ContextVar("widget_refresh", default=30) - - -async def _capture_refresh(request: Request) -> None: - raw = request.query_params.get("refresh") - try: - _REFRESH.set(max(5, min(int(raw), 3600)) if raw else 30) - except (TypeError, ValueError): - _REFRESH.set(30) - - -router = APIRouter(dependencies=[Depends(_capture_refresh), Depends(require_suite_token)]) -_s = get_settings() -_DB = _s.db_path - -# ── Manifest ────────────────────────────────────────────────────────────────── -# `category` groups these in pktHub's NOC library picker. Every data surface the -# app renders in its own UI should have an entry here — the NOC builder can only -# offer what this list declares. -_AP_PARAM = { - "key": "ap_id", "label": "Access Point", "type": "select", - "options_path": "/api/widgets/options/access_points", -} -_WINDOW_PARAM = { - "key": "hours", "label": "Window", "type": "select", - "options": [{"value": "1", "label": "1 hour"}, {"value": "6", "label": "6 hours"}, - {"value": "24", "label": "24 hours"}, {"value": "168", "label": "7 days"}], -} - -MANIFEST = [ - # ── Overview ────────────────────────────────────────────────────────────── - { - "id": "wifi_summary", "title": "WiFi Summary", "category": "Overview", - "description": "Access point, client and rogue counts across the estate", - "view_path": "/api/widgets/wifi_summary", - "default_w": 560, "default_h": 200, "min_w": 300, "min_h": 150, - }, - { - "id": "alert_summary", "title": "Alert Summary", "category": "Overview", - "description": "Active alert counts by severity", - "view_path": "/api/widgets/alert_summary", - "default_w": 420, "default_h": 200, "min_w": 260, "min_h": 150, - }, - { - "id": "aps_by_site", "title": "APs by Site", "category": "Overview", - "description": "Access point count and offline count per site", - "view_path": "/api/widgets/aps_by_site", - "default_w": 480, "default_h": 320, "min_w": 280, "min_h": 200, - }, - - # ── Access Points ───────────────────────────────────────────────────────── - { - "id": "ap_status", "title": "AP Status", "category": "Access Points", - "description": "All access points with site, status, and connected client count", - "view_path": "/api/widgets/ap_status", - "default_w": 640, "default_h": 380, "min_w": 340, "min_h": 220, - }, - { - "id": "ap_uptime", "title": "AP Uptime", "category": "Access Points", - "description": "Reported uptime per access point, least stable first", - "view_path": "/api/widgets/ap_uptime", - "default_w": 540, "default_h": 340, "min_w": 300, "min_h": 200, - }, - { - "id": "rogue_aps", "title": "Rogue APs", "category": "Access Points", - "description": "Access points flagged as rogue", - "view_path": "/api/widgets/rogue_aps", - "default_w": 620, "default_h": 320, "min_w": 320, "min_h": 180, - }, - - # ── Radios ──────────────────────────────────────────────────────────────── - { - "id": "radio_overview", "title": "Radio Overview", "category": "Radios", - "description": "Per-radio band, channel, width, power, utilization and noise", - "view_path": "/api/widgets/radio_overview", - "default_w": 780, "default_h": 400, "min_w": 380, "min_h": 220, - }, - { - "id": "channel_utilization", "title": "Channel Utilization", "category": "Radios", - "description": "Busiest radios by channel utilization", - "view_path": "/api/widgets/channel_utilization", - "default_w": 540, "default_h": 340, "min_w": 300, "min_h": 200, - }, - { - "id": "noise_floor", "title": "Noise Floor", "category": "Radios", - "description": "Radios with the highest noise floor", - "view_path": "/api/widgets/noise_floor", - "default_w": 540, "default_h": 340, "min_w": 300, "min_h": 200, - }, - - # ── Clients ─────────────────────────────────────────────────────────────── - { - "id": "client_count", "title": "Client Count", "category": "Clients", - "description": "Connected client count by radio band for one access point", - "view_path": "/api/widgets/client_count", - "default_w": 460, "default_h": 300, "min_w": 280, "min_h": 180, - "params": [_AP_PARAM], - }, - { - "id": "clients_by_band", "title": "Clients by Band", "category": "Clients", - "description": "Connected client distribution across radio bands", - "view_path": "/api/widgets/clients_by_band", - "default_w": 440, "default_h": 280, "min_w": 260, "min_h": 170, - }, - { - "id": "clients_by_ssid", "title": "Clients by SSID", "category": "Clients", - "description": "Connected client distribution across SSIDs", - "view_path": "/api/widgets/clients_by_ssid", - "default_w": 480, "default_h": 300, "min_w": 280, "min_h": 180, - }, - { - "id": "client_health", "title": "Client Signal Health", "category": "Clients", - "description": "Clients bucketed by signal strength, weakest listed first", - "view_path": "/api/widgets/client_health", - "default_w": 620, "default_h": 360, "min_w": 320, "min_h": 200, - }, - { - "id": "client_events", "title": "Client Events", "category": "Clients", - "description": "Recent associate, roam, deauth and auth-failure events", - "view_path": "/api/widgets/client_events", - "default_w": 700, "default_h": 360, "min_w": 340, "min_h": 200, - }, - - # ── Trends (charts) ─────────────────────────────────────────────────────── - { - "id": "radio_trend", "title": "Radio Trend", "category": "Trends", - "description": "Utilization, clients and noise over time for one radio", - "view_path": "/api/widgets/radio_trend", - "default_w": 680, "default_h": 320, "min_w": 320, "min_h": 180, - "params": [ - _AP_PARAM, - # {ap_id} is substituted from the widget's own config by pktHub, so the - # radio list reflects whatever bands that AP currently reports. - {"key": "radio_id", "label": "Radio", "type": "select", - "options_path": "/api/widgets/options/radios?ap_id={ap_id}"}, - {"key": "metric", "label": "Metric", "type": "select", - "options": [{"value": "utilization_pct", "label": "Utilization %"}, - {"value": "client_count", "label": "Clients"}, - {"value": "noise_floor_dbm", "label": "Noise floor"}, - {"value": "retry_pct", "label": "Retry %"}, - {"value": "crc_error_pct", "label": "CRC error %"}, - {"value": "tx_power_dbm", "label": "TX power"}]}, - _WINDOW_PARAM, - ], - }, - { - "id": "client_trend", "title": "Client Trend", "category": "Trends", - "description": "Total connected clients over time", - "view_path": "/api/widgets/client_trend", - "default_w": 620, "default_h": 300, "min_w": 300, "min_h": 170, - "params": [_WINDOW_PARAM], - }, - - { - "id": "airtime_trend", "title": "Airtime by Band", "category": "Trends", - "description": "Average channel utilization per band over time, across all radios", - "view_path": "/api/widgets/airtime_trend", - "default_w": 680, "default_h": 320, "min_w": 320, "min_h": 180, - "params": [_WINDOW_PARAM], - }, - { - "id": "rf_spectrum", "title": "RF Spectrum", "category": "Radios", - "description": "Where each radio sits in the 2.4, 5 and 6 GHz bands; height is channel utilization", - "view_path": "/api/widgets/rf_spectrum", - "default_w": 760, "default_h": 420, "min_w": 400, "min_h": 260, - }, - { - "id": "signal_scope", "title": "Signal Scope", "category": "Clients", - "description": "Every connected client by the access point it is on; range is signal strength", - "view_path": "/api/widgets/signal_scope", - "default_w": 520, "default_h": 460, "min_w": 300, "min_h": 300, - }, - - # ── Alerts ──────────────────────────────────────────────────────────────── - { - "id": "active_alerts", "title": "Active Alerts", "category": "Alerts", - "description": "Unresolved WiFi alert events", - "view_path": "/api/widgets/active_alerts", - "default_w": 640, "default_h": 360, "min_w": 320, "min_h": 200, - }, - - # ── Collectors ──────────────────────────────────────────────────────────── - { - "id": "collector_status", "title": "Collector Status", "category": "Collectors", - "description": "Controller/collector health and last successful poll", - "view_path": "/api/widgets/collector_status", - "default_w": 620, "default_h": 300, "min_w": 320, "min_h": 180, - }, -] - - -@router.get("/manifest") -async def widget_manifest(): - return MANIFEST - - - -# ── Widget states ────────────────────────────────────────────────────────────── -# A blank tile on a wallboard reads as "all quiet", so the three reasons a widget -# can show nothing must look different from each other: -# empty — the query ran and there genuinely is nothing -# cfg — the widget needs a param chosen in the NOC editor before it can run -# err — the query failed; this must never be mistaken for "nothing to report" -# Query helpers record failures here rather than swallowing them; _page() renders -# the error state instead of whatever half-built body the caller produced. The -# ContextVar is per-request: each request runs in its own task context. -_WIDGET_ERR: ContextVar = ContextVar("widget_err", default=None) - - -def _note_err(exc: BaseException) -> None: - _WIDGET_ERR.set(f"{type(exc).__name__}: {exc}"[:200]) - - -def _state(kind: str, msg: str, sub: str = "") -> str: - icon = {"empty": "○", "cfg": "⚙", "err": "⚠"}.get(kind, "○") - sub_html = f'
{html.escape(str(sub))}
' if sub else "" - return (f'
{icon}
' - f'
{html.escape(str(msg))}
{sub_html}
') - - -def _empty(msg: str) -> str: - return _state("empty", msg) - - -def _needs(msg: str) -> str: - """The widget is fine — it is waiting on a filter the NOC editor must set.""" - return _state("cfg", msg, "Select it in the widget's Filters panel") - - -# ── Shared page shell ─────────────────────────────────────────────────────────── -def _page(title: str, body: str) -> str: - # Widget titles carry device/metric/subnet names chosen in the NOC editor - # and read back from device data, and these pages render on an - # unauthenticated display URL — escape before interpolating. - title = html.escape(str(title)) - # A failed query leaves a body saying "nothing here" — which is a lie. - _err = _WIDGET_ERR.get() - if _err: - body = _state("err", "Widget unavailable", _err) - return f""" - -{title} - - - -
{title}
-
{body}
-""" - - -def _status_badge(status: str) -> str: - s = (status or "").lower() - if s in ("online", "ok"): - return '{}'.format(html.escape(s.upper())) - if s in ("offline", "error"): - return '{}'.format(html.escape(s.upper())) - return f'{html.escape((status or "UNKNOWN").upper())}' - - -# ── Time window ───────────────────────────────────────────────────────────────── -def _since(hours: int) -> str: - return (datetime.now(timezone.utc) - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") - - -# ── AP lookup ─────────────────────────────────────────────────────────────────── -async def _ap_name(ap_id: int) -> str | None: - """None when the AP is gone. A NOC screen outlives the estate it was built - against, so a widget pinned to a decommissioned AP has to say so rather than - render an empty frame the wall-watcher reads as 'all quiet'.""" - try: - async with aiosqlite.connect(_DB) as db: - db.row_factory = aiosqlite.Row - async with db.execute("SELECT name FROM access_points WHERE id=?", (ap_id,)) as cur: - row = await cur.fetchone() - return row["name"] if row else None - except Exception: - return None - - -def _gone(what: str) -> str: - return _empty(f'{html.escape(what)} no longer exists') - - -# ── Formatting ────────────────────────────────────────────────────────────────── -def _fmt_n(n) -> str: - try: - n = float(n or 0) - except (TypeError, ValueError): - return "—" - for div, suf in ((1_000_000_000, "G"), (1_000_000, "M"), (1_000, "K")): - if abs(n) >= div: - return f"{n / div:.1f}{suf}" - return f"{n:.0f}" if n == int(n) else f"{n:.1f}" - - -def _fmt_ts(ts) -> str: - return str(ts)[:19].replace("T", " ") if ts else "—" - - -def _fmt_uptime(seconds) -> str: - try: - secs = int(seconds or 0) - except (TypeError, ValueError): - return "—" - d, rem = divmod(secs, 86400) - h, rem = divmod(rem, 3600) - return f"{d}d {h}h" if d else f"{h}h {rem // 60}m" - - -# ── Tiles / bars ──────────────────────────────────────────────────────────────── -def _tiles(pairs) -> str: - return '
' + "".join( - f'
{html.escape(str(label))}
' - f'
{html.escape(str(value))}
' - for label, value in pairs - ) + "
" - - -def _bars(rows, color: str = "#fb923c") -> str: - """rows = [(label, numeric_value, display_value)] — scaled to the largest.""" - peak = max((r[1] or 0) for r in rows) if rows else 0 - return "".join( - f'
{html.escape(str(lbl))}
' - f'
' - f'
{html.escape(str(disp))}
' - for lbl, val, disp in rows - ) - - -# ── Inline SVG line chart ─────────────────────────────────────────────────────── -# Server-rendered so the iframe stays dependency-free — pktWiFi ships no charting -# library to these views, and the NOC display must render without network access -# to anything but this app. -_SERIES_COLORS = ("#fb923c", "#60a5fa", "#4ade80", "#f87171", "#a78bfa") - - -def _line_chart(series, fmt=_fmt_n, height: int = 120) -> str: - """series = [(label, [float, ...])] — equal-length samples, oldest first.""" - series = [(lbl, [v for v in vals if v is not None]) for lbl, vals in series] - series = [(lbl, vals) for lbl, vals in series if len(vals) >= 2] - if not series: - return _empty('No samples in window') - - W, H, PAD = 600, height, 4 - lo = min(min(v) for _, v in series) - hi = max(max(v) for _, v in series) - span = (hi - lo) or 1.0 - - def _y(v: float) -> float: - return PAD + (H - 2 * PAD) * (1 - (v - lo) / span) - - paths, legend = [], [] - for i, (lbl, vals) in enumerate(series): - color = _SERIES_COLORS[i % len(_SERIES_COLORS)] - step = W / (len(vals) - 1) - pts = [(j * step, _y(v)) for j, v in enumerate(vals)] - line = "M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in pts) - area = f"{line} L{W:.1f},{H} L0,{H} Z" - paths.append( - f'' - f'' - ) - legend.append( - f'{html.escape(str(lbl))} ' - f'{html.escape(fmt(vals[-1]))}' - ) - - meta = (f'
min {html.escape(fmt(lo))}' - f'max {html.escape(fmt(hi))}' - f'samples {max(len(v) for _, v in series)}
') - return ( - f'
{meta}' - f'{"".join(paths)}' - f'
{"".join(legend)}
' - ) - - -# ── AP Status widget ────────────────────────────────────────────────────────── -@router.get("/ap_status", response_class=HTMLResponse, include_in_schema=False) -async def widget_ap_status(): - rows = [] - try: - async with aiosqlite.connect(_DB) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - f"""SELECT ap.id, ap.name, ap.site, ap.status, - COALESCE((SELECT SUM(r.client_count) FROM radios r - WHERE r.access_point_id = ap.id AND {radio_is_current()}), 0) AS clients - FROM access_points ap - ORDER BY CASE ap.status WHEN 'offline' THEN 0 WHEN 'unknown' THEN 1 ELSE 2 END, ap.name""" - ) as cur: - rows = [dict(r) for r in await cur.fetchall()] - except Exception as exc: - _note_err(exc) - - if rows: - trs = "".join( - f"{html.escape(str(r['name']))}{html.escape(str(r.get('site') or ''))}" - f"{_status_badge(r['status'])}{r['clients']}" - for r in rows - ) - body = ( - "" - f"{trs}
Access PointSiteStatusClients
" - ) - else: - body = _empty('No access points') - return HTMLResponse(_page("AP Status", body)) - - -# ── Client Count widget (per-AP, dynamic) ──────────────────────────────────── -@router.get("/client_count", response_class=HTMLResponse, include_in_schema=False) -async def widget_client_count(ap_id: int | None = None): - if not ap_id: - return HTMLResponse(_page("Client Count", _needs('Select an access point'))) - - ap_name = await _ap_name(ap_id) - if ap_name is None: - return HTMLResponse(_page("Client Count", _gone(f"Access point {ap_id}"))) - - bands = [] - try: - async with aiosqlite.connect(_DB) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - f"""SELECT r.band, r.client_count - FROM radios r JOIN access_points ap ON ap.id = r.access_point_id - WHERE r.access_point_id = ? AND {radio_is_current()} ORDER BY r.band""", - (ap_id,), - ) as cur: - bands = [dict(r) for r in await cur.fetchall()] - except Exception as exc: - _note_err(exc) - - total = sum(b["client_count"] or 0 for b in bands) - tiles = "".join( - f'
{html.escape(str(b["band"]))}
{b["client_count"] or 0}
' - for b in bands - ) or _empty('No radios discovered on any access point') - body = ( - f'
{html.escape(str(ap_name))}
' - f'
Total Clients
{total}
' - f'
{tiles}
' - ) - return HTMLResponse(_page("Client Count", body)) - - -# ── Active Alerts widget ────────────────────────────────────────────────────── -@router.get("/active_alerts", response_class=HTMLResponse, include_in_schema=False) -async def widget_active_alerts(): - rows = [] - try: - async with aiosqlite.connect(_DB) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - """SELECT ae.severity, ae.message, ae.created_at, ap.name AS ap_name - FROM alert_events ae LEFT JOIN access_points ap ON ap.id = ae.access_point_id - WHERE ae.active = 1 AND ae.acked = 0 - ORDER BY ae.created_at DESC LIMIT 40""" - ) as cur: - rows = [dict(r) for r in await cur.fetchall()] - except Exception as exc: - _note_err(exc) - - if rows: - trs = "".join( - f"{_status_badge('offline' if r['severity'] == 'critical' else 'unknown')}" - f"{html.escape(str(r.get('ap_name') or ''))}{html.escape(str(r['message']))}" - f"{html.escape(str(r['created_at'])[:19].replace('T',' '))}" - for r in rows - ) - body = ( - "" - f"{trs}
SeverityAPMessageFired
" - ) - else: - body = _empty('No active alerts') - return HTMLResponse(_page("Active Alerts", body)) - - -# ── Query helper ────────────────────────────────────────────────────────────── -async def _rows(sql: str, params: tuple = ()) -> list[dict]: - try: - async with aiosqlite.connect(_DB) as db: - db.row_factory = aiosqlite.Row - async with db.execute(sql, params) as cur: - return [dict(r) for r in await cur.fetchall()] - except Exception as exc: - _note_err(exc) - return [] - - -# ── WiFi Summary widget ─────────────────────────────────────────────────────── -@router.get("/wifi_summary", response_class=HTMLResponse, include_in_schema=False) -async def widget_wifi_summary(): - ap = await _rows( - """SELECT COUNT(*) AS total, - SUM(CASE WHEN status='online' THEN 1 ELSE 0 END) AS online, - SUM(CASE WHEN status='offline' THEN 1 ELSE 0 END) AS offline, - SUM(CASE WHEN is_rogue=1 THEN 1 ELSE 0 END) AS rogue - FROM access_points""" - ) - cli = await _rows( - f"""SELECT COUNT(*) AS clients - FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id - WHERE {client_is_current()}""" - ) - a = ap[0] if ap else {} - body = _tiles([ - ("APs", a.get("total") or 0), - ("Online", a.get("online") or 0), - ("Offline", a.get("offline") or 0), - ("Rogue", a.get("rogue") or 0), - ("Clients", (cli[0]["clients"] if cli else 0) or 0), - ]) - return HTMLResponse(_page("WiFi Summary", body)) - - -# ── Alert Summary widget ────────────────────────────────────────────────────── -@router.get("/alert_summary", response_class=HTMLResponse, include_in_schema=False) -async def widget_alert_summary(): - rows = await _rows( - "SELECT LOWER(severity) AS sev, COUNT(*) AS n FROM alert_events " - "WHERE active = 1 AND acked = 0 GROUP BY sev" - ) - counts = {r["sev"]: r["n"] for r in rows} - body = _tiles([ - ("Active", sum(counts.values())), - ("Critical", counts.get("critical", 0)), - ("Warning", counts.get("warning", 0)), - ("Info", counts.get("info", 0)), - ]) - return HTMLResponse(_page("Alert Summary", body)) - - -# ── APs by Site widget ──────────────────────────────────────────────────────── -@router.get("/aps_by_site", response_class=HTMLResponse, include_in_schema=False) -async def widget_aps_by_site(): - rows = await _rows( - """SELECT CASE WHEN site IS NULL OR site = '' THEN 'Unassigned' ELSE site END AS site, - COUNT(*) AS total, - SUM(CASE WHEN status='offline' THEN 1 ELSE 0 END) AS offline - FROM access_points GROUP BY site ORDER BY total DESC LIMIT 20""" - ) - body = _bars([ - (r["site"], r["total"], f"{r['total']}" + (f" · {r['offline']}↓" if r["offline"] else "")) - for r in rows - ]) if rows else _empty('No access points') - return HTMLResponse(_page("APs by Site", body)) - - -# ── AP Uptime widget ────────────────────────────────────────────────────────── -@router.get("/ap_uptime", response_class=HTMLResponse, include_in_schema=False) -async def widget_ap_uptime(): - rows = await _rows( - "SELECT name, uptime_seconds FROM access_points " - "WHERE uptime_seconds IS NOT NULL ORDER BY uptime_seconds ASC LIMIT 30" - ) - body = _bars([ - (r["name"], float(r["uptime_seconds"] or 0), _fmt_uptime(r["uptime_seconds"])) - for r in rows - ], color="#60a5fa") if rows else _empty('No access point is reporting uptime') - return HTMLResponse(_page("AP Uptime", body)) - - -# ── Rogue APs widget ────────────────────────────────────────────────────────── -@router.get("/rogue_aps", response_class=HTMLResponse, include_in_schema=False) -async def widget_rogue_aps(): - rows = await _rows( - "SELECT name, mac_address, site, vendor, last_seen FROM access_points " - "WHERE is_rogue = 1 ORDER BY last_seen DESC LIMIT 40" - ) - if rows: - trs = "".join( - f"{html.escape(str(r['name']))}{html.escape(str(r.get('mac_address') or ''))}" - f"{html.escape(str(r.get('site') or ''))}{html.escape(str(r.get('vendor') or ''))}" - f"{html.escape(_fmt_ts(r.get('last_seen')))}" - for r in rows - ) - body = ("" - f"{trs}
NameMACSiteVendorLast Seen
") - else: - body = _empty('No rogue APs detected') - return HTMLResponse(_page("Rogue APs", body)) - - -# ── Radio Overview widget ───────────────────────────────────────────────────── -@router.get("/radio_overview", response_class=HTMLResponse, include_in_schema=False) -async def widget_radio_overview(): - rows = await _rows( - f"""SELECT ap.name AS ap_name, r.band, r.channel, r.channel_width_mhz, - r.tx_power_dbm, r.utilization_pct, r.noise_floor_dbm, r.client_count - FROM radios r JOIN access_points ap ON ap.id = r.access_point_id - WHERE {radio_is_current()} - ORDER BY r.utilization_pct DESC, ap.name LIMIT 60""" - ) - if rows: - trs = "".join( - f"{html.escape(str(r['ap_name']))}{html.escape(str(r.get('band') or ''))}" - f"{r.get('channel') if r.get('channel') is not None else '—'}" - f"{('/' + str(r['channel_width_mhz'])) if r.get('channel_width_mhz') else ''}" - f"{_fmt_n(r['tx_power_dbm']) + ' dBm' if r.get('tx_power_dbm') is not None else '—'}" - f"{_fmt_n(r['utilization_pct']) + '%' if r.get('utilization_pct') is not None else '—'}" - f"{_fmt_n(r['noise_floor_dbm']) + ' dBm' if r.get('noise_floor_dbm') is not None else '—'}" - f"{r.get('client_count') or 0}" - for r in rows - ) - body = ("" - "" - f"{trs}
APBandCh/WTXUtilNoiseClients
") - else: - body = _empty('No radios discovered on any access point') - return HTMLResponse(_page("Radio Overview", body)) - - -# ── Channel Utilization widget ──────────────────────────────────────────────── -@router.get("/channel_utilization", response_class=HTMLResponse, include_in_schema=False) -async def widget_channel_utilization(): - rows = await _rows( - f"""SELECT ap.name AS ap_name, r.band, r.channel, r.utilization_pct - FROM radios r JOIN access_points ap ON ap.id = r.access_point_id - WHERE r.utilization_pct IS NOT NULL AND {radio_is_current()} - ORDER BY r.utilization_pct DESC LIMIT 25""" - ) - body = _bars([ - (f"{r['ap_name']} · {r.get('band') or ''}{(' ch' + str(r['channel'])) if r.get('channel') else ''}", - float(r["utilization_pct"] or 0), f"{float(r['utilization_pct'] or 0):.0f}%") - for r in rows - ]) if rows else _empty('No radio is reporting channel utilization') - return HTMLResponse(_page("Channel Utilization", body)) - - -# ── Noise Floor widget ──────────────────────────────────────────────────────── -@router.get("/noise_floor", response_class=HTMLResponse, include_in_schema=False) -async def widget_noise_floor(): - # Noise floor is negative dBm — closer to zero is worse, so rank descending. - rows = await _rows( - f"""SELECT ap.name AS ap_name, r.band, r.noise_floor_dbm - FROM radios r JOIN access_points ap ON ap.id = r.access_point_id - WHERE r.noise_floor_dbm IS NOT NULL AND {radio_is_current()} - ORDER BY r.noise_floor_dbm DESC LIMIT 25""" - ) - if rows: - # Bars scale on distance from a -100 dBm floor so the worst radio is longest. - body = _bars([ - (f"{r['ap_name']} · {r.get('band') or ''}", - max(0.0, 100.0 + float(r["noise_floor_dbm"])), f"{float(r['noise_floor_dbm']):.0f} dBm") - for r in rows - ], color="#f87171") - else: - body = _empty('No radio is reporting a noise floor') - return HTMLResponse(_page("Noise Floor", body)) - - -# ── Clients by Band widget ──────────────────────────────────────────────────── -@router.get("/clients_by_band", response_class=HTMLResponse, include_in_schema=False) -async def widget_clients_by_band(): - rows = await _rows( - "SELECT COALESCE(NULLIF(band,''),'unknown') AS band, COUNT(*) AS n " - "FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id " - f"WHERE {client_is_current()} GROUP BY band ORDER BY n DESC" - ) - body = _bars([(r["band"], r["n"], str(r["n"])) for r in rows]) \ - if rows else _empty('No clients are currently associated') - return HTMLResponse(_page("Clients by Band", body)) - - -# ── Clients by SSID widget ──────────────────────────────────────────────────── -@router.get("/clients_by_ssid", response_class=HTMLResponse, include_in_schema=False) -async def widget_clients_by_ssid(): - rows = await _rows( - "SELECT COALESCE(NULLIF(ssid,''),'unknown') AS ssid, COUNT(*) AS n " - "FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id " - f"WHERE {client_is_current()} GROUP BY ssid ORDER BY n DESC LIMIT 20" - ) - body = _bars([(r["ssid"], r["n"], str(r["n"])) for r in rows]) \ - if rows else _empty('No clients are currently associated') - return HTMLResponse(_page("Clients by SSID", body)) - - -# ── Client Signal Health widget ─────────────────────────────────────────────── -@router.get("/client_health", response_class=HTMLResponse, include_in_schema=False) -async def widget_client_health(): - rows = await _rows( - f"""SELECT c.hostname, c.mac_address, c.ssid, c.band, c.rssi_dbm, c.snr_db, - c.tx_rate_mbps, ap.name AS ap_name - FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id - WHERE c.rssi_dbm IS NOT NULL AND {client_is_current()} ORDER BY c.rssi_dbm ASC LIMIT 40""" - ) - if rows: - def _sig(rssi) -> str: - r = float(rssi) - if r >= -65: - return 'GOOD' - if r >= -75: - return 'FAIR' - return 'POOR' - - trs = "".join( - f"{html.escape(str(r.get('hostname') or r.get('mac_address') or ''))}" - f"{html.escape(str(r.get('ap_name') or ''))}" - f"{html.escape(str(r.get('ssid') or ''))}" - f"{_sig(r['rssi_dbm'])} {float(r['rssi_dbm']):.0f}" - f"{_fmt_n(r['snr_db']) if r.get('snr_db') is not None else '—'}" - f"{_fmt_n(r['tx_rate_mbps']) if r.get('tx_rate_mbps') is not None else '—'}" - for r in rows - ) - body = ("" - "" - f"{trs}
ClientAPSSIDRSSISNRTX Mbps
") - else: - body = _empty('No client is reporting signal strength') - return HTMLResponse(_page("Client Signal Health", body)) - - -# ── Client Events widget ────────────────────────────────────────────────────── -@router.get("/client_events", response_class=HTMLResponse, include_in_schema=False) -async def widget_client_events(): - rows = await _rows( - """SELECT e.ts, e.mac_address, e.event_type, - f.name AS from_ap, t.name AS to_ap - FROM client_events e - LEFT JOIN access_points f ON f.id = e.from_ap_id - LEFT JOIN access_points t ON t.id = e.to_ap_id - ORDER BY e.ts DESC LIMIT 40""" - ) - if rows: - def _evt(t: str) -> str: - t = (t or "").lower() - if t in ("deauth", "auth_fail"): - return f'{html.escape(t.upper())}' - if t == "roam": - return 'ROAM' - return f'{html.escape(t.upper())}' - - trs = "".join( - f"{html.escape(_fmt_ts(r['ts']))}{html.escape(str(r['mac_address']))}" - f"{_evt(r['event_type'])}" - f"{html.escape(str(r.get('from_ap') or '—'))} → {html.escape(str(r.get('to_ap') or '—'))}" - for r in rows - ) - body = ("" - f"{trs}
TimeClientEventAP
") - else: - body = _empty('No client events') - return HTMLResponse(_page("Client Events", body)) - - -# ── Radio Trend widget (chart) ──────────────────────────────────────────────── -_RADIO_METRICS = { - "utilization_pct", "client_count", "noise_floor_dbm", - "retry_pct", "crc_error_pct", "tx_power_dbm", -} - - -@router.get("/radio_trend", response_class=HTMLResponse, include_in_schema=False) -async def widget_radio_trend( - ap_id: int | None = None, radio_id: int | None = None, - metric: str = "utilization_pct", hours: int = 6, -): - if not radio_id: - return HTMLResponse(_page("Radio Trend", _needs('Select an access point and radio'))) - if ap_id and await _ap_name(ap_id) is None: - return HTMLResponse(_page("Radio Trend", _gone(f"Access point {ap_id}"))) - # Allow-list the column — it is interpolated into the SELECT, and a metric - # name arrives from the widget's saved config. - if metric not in _RADIO_METRICS: - metric = "utilization_pct" - - hours = max(1, min(int(hours or 6), 720)) - rows = await _rows( - f"SELECT ts, {metric} AS v FROM radio_metrics " - "WHERE radio_id = ? AND ts >= ? AND " - f"{metric} IS NOT NULL ORDER BY ts ASC LIMIT 2000", - (radio_id, _since(hours)), - ) - if not rows: - return HTMLResponse(_page("Radio Trend", _empty('No samples in window'))) - - band = await _rows("SELECT band FROM radios WHERE id = ?", (radio_id,)) - label = f"{band[0]['band']} {metric}" if band else metric - body = _line_chart([(label, [r["v"] for r in rows])]) - return HTMLResponse(_page(f"{label} — last {hours}h", body)) - - -# ── Airtime by Band widget (chart) ──────────────────────────────────────────── -@router.get("/airtime_trend", response_class=HTMLResponse, include_in_schema=False) -async def widget_airtime_trend(hours: int = 6): - hours = max(1, min(int(hours or 6), 720)) - bucket = max(60, (hours * 3600) // 120) - rows = await _rows( - """SELECT (CAST(strftime('%s', m.ts) AS INTEGER) / ?) AS b, r.band AS band, - AVG(m.utilization_pct) AS util - FROM radio_metrics m JOIN radios r ON r.id = m.radio_id - WHERE m.ts >= ? AND m.utilization_pct IS NOT NULL - AND r.band IN ('2.4GHz','5GHz','6GHz') - GROUP BY b, r.band ORDER BY b""", - (bucket, _since(hours)), - ) - if not rows: - return HTMLResponse(_page("Airtime by Band", _empty('No radio reported utilization in this window'))) - buckets = sorted({r["b"] for r in rows}) - series = [] - for band in ("2.4GHz", "5GHz", "6GHz"): - have = {r["b"]: r["util"] for r in rows if r["band"] == band} - if not have: - continue - # Carry the last reading across a gap (and the first one back to the - # start) so every band spans the same buckets. - first = have[min(have)] - vals, last = [], first - for b in buckets: - last = have.get(b, last) - vals.append(float(last)) - series.append((band, vals)) - body = _line_chart(series, fmt=lambda v: f"{v:.0f}%") - return HTMLResponse(_page(f"Airtime by Band — last {hours}h", body)) - - -# ── RF Spectrum widget ──────────────────────────────────────────────────────── -@router.get("/rf_spectrum", response_class=HTMLResponse, include_in_schema=False) -async def widget_rf_spectrum(): - from app.wifi.rf import BANDS, band_axis, occupied_span - rows = await _rows( - f"""SELECT ap.name AS ap_name, r.band, r.channel, r.channel_width_mhz, r.utilization_pct - FROM radios r JOIN access_points ap ON ap.id = r.access_point_id - WHERE r.band IN ('2.4GHz','5GHz','6GHz') AND {radio_is_current()}""" - ) - placed = [] - for r in rows: - span = occupied_span(r["band"], r["channel"], r["channel_width_mhz"]) - if span: - placed.append({**r, **span}) - if not placed: - return HTMLResponse(_page("RF Spectrum", _empty('No radio has a usable channel'))) - - colors = {"2.4GHz": "#fb923c", "5GHz": "#60a5fa", "6GHz": "#a78bfa"} - W, LANE, GAP = 600, 96, 22 - lanes, y0 = [], 0 - for band in BANDS: - mine = [p for p in placed if p["band"] == band] - if not mine: - continue - ax = band_axis(band) - lo, hi = ax["lo_mhz"], ax["hi_mhz"] - X = lambda mhz: (mhz - lo) / (hi - lo) * W - base = y0 + LANE - parts = [f'{html.escape(band)}', - f''] - for t in ax["ticks"]: - if t["major"]: - x = X(t["mhz"]) - parts.append(f'' - f'{t["channel"]}') - for p in mine: - util = p["utilization_pct"] - h = max(4.0, (float(util) if util is not None else 8.0) / 100 * (LANE - 18)) - x, w = X(p["lo_mhz"]), max(2.0, X(p["hi_mhz"]) - X(p["lo_mhz"])) - dash = ' stroke-dasharray="3 2" stroke="#e2e8f0" stroke-width="1"' \ - if (p["approximate"] or not p["width_reported"] or util is None) else "" - tip = f'{p["ap_name"]} · ch {p["channel"]} · ' + (f'{float(util):.0f}%' if util is not None else 'no utilization') - parts.append(f'{html.escape(tip)}') - lanes.append("".join(parts)) - y0 += LANE + GAP - body = (f'' - f'{"".join(lanes)}' - '
height = channel utilization · ' - 'dashed = width not reported, position approximate or no utilization
') - return HTMLResponse(_page("RF Spectrum", body)) - - -# ── Signal Scope widget ─────────────────────────────────────────────────────── -@router.get("/signal_scope", response_class=HTMLResponse, include_in_schema=False) -async def widget_signal_scope(): - import math - import zlib - from app.wifi.rf import SIGNAL_FAIR_DBM, SIGNAL_GOOD_DBM - rows = await _rows( - f"""SELECT ap.id AS ap_id, ap.name AS ap_name, c.rssi_dbm, c.mac_address - FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id - WHERE c.rssi_dbm IS NOT NULL AND {client_is_current()}""" - ) - if not rows: - return HTMLResponse(_page("Signal Scope", _empty('No client is reporting a signal'))) - by_ap: dict[int, list[dict]] = {} - for r in rows: - by_ap.setdefault(r["ap_id"], []).append(r) - ranked = sorted(by_ap.values(), key=lambda m: (-len(m), m[0]["ap_name"]))[:16] - - CX = CY = 200 - R_CORE, R_MAX, STRONG, WEAK = 20, 150, -30, -95 - rng = lambda v: R_CORE + (R_MAX - R_CORE) * (STRONG - max(WEAK, min(STRONG, v))) / (STRONG - WEAK) - n = len(ranked) - parts = [] - for dbm, lbl in ((SIGNAL_GOOD_DBM, "good"), (SIGNAL_FAIR_DBM, "fair")): - parts.append(f'{dbm} dBm') - for i, members in enumerate(ranked): - a0, a1 = 360 * i / n, 360 * (i + 1) / n - mid = math.radians((a0 + a1) / 2) - sx, sy = CX + R_MAX * math.sin(math.radians(a0)), CY - R_MAX * math.cos(math.radians(a0)) - parts.append(f'') - lx, ly = CX + (R_MAX + 14) * math.sin(mid), CY - (R_MAX + 14) * math.cos(mid) - name = str(members[0]["ap_name"]) - parts.append(f'' - f'{html.escape(name[:14])} · {len(members)}') - for j, c in enumerate(sorted(members, key=lambda m: m["rssi_dbm"])[:120]): - # Where a client sits around its sector means nothing; it is spread - # out only so the dots do not stack. - frac = ((zlib.crc32(f'{c["mac_address"]}|{i}'.encode()) % 1000) / 1000) * 0.8 + 0.1 - ang = math.radians(a0 + (a1 - a0) * frac) - r = rng(float(c["rssi_dbm"])) - col = ("#4ade80" if c["rssi_dbm"] >= SIGNAL_GOOD_DBM - else "#fbbf24" if c["rssi_dbm"] >= SIGNAL_FAIR_DBM else "#f87171") - parts.append(f'') - body = ('{"".join(parts)}') - return HTMLResponse(_page("Signal Scope", body)) - - -# ── Client Trend widget (chart) ─────────────────────────────────────────────── -@router.get("/client_trend", response_class=HTMLResponse, include_in_schema=False) -async def widget_client_trend(hours: int = 6): - hours = max(1, min(int(hours or 6), 720)) - # Sum across radios per sample bucket — radio_metrics holds one row per radio. - rows = await _rows( - """SELECT substr(ts, 1, 16) AS bucket, SUM(client_count) AS n - FROM radio_metrics WHERE ts >= ? AND client_count IS NOT NULL - GROUP BY bucket ORDER BY bucket ASC LIMIT 2000""", - (_since(hours),), - ) - body = _line_chart([("Clients", [r["n"] for r in rows])]) - return HTMLResponse(_page(f"Client Trend — last {hours}h", body)) - - -# ── Collector Status widget ─────────────────────────────────────────────────── -@router.get("/collector_status", response_class=HTMLResponse, include_in_schema=False) -async def widget_collector_status(): - rows = await _rows( - "SELECT name, collector_type, enabled, status, last_poll_at, last_error " - "FROM collectors ORDER BY name" - ) - if rows: - trs = "".join( - f"{html.escape(str(r['name']))}{html.escape(str(r.get('collector_type') or ''))}" - f"{_status_badge(r.get('status') if r.get('enabled') else 'disabled')}" - f"{html.escape(_fmt_ts(r.get('last_poll_at')))}" - f"{html.escape(str(r.get('last_error') or ''))[:60]}" - for r in rows - ) - body = ("" - "" - f"{trs}
CollectorTypeStatusLast PollError
") - else: - body = _empty('No collectors') - return HTMLResponse(_page("Collector Status", body)) - - -# ── Param option pickers ────────────────────────────────────────────────────── -# Every picker reads live state rather than a static list, so an AP or radio -# added or removed after a NOC screen was built shows up (or drops out) the next -# time the editor opens the param — no manifest edit and no pktHub change needed. -@router.get("/options/access_points") -async def widget_options_access_points(): - rows = await _rows("SELECT id, name, site FROM access_points ORDER BY name") - return JSONResponse([ - {"value": str(r["id"]), "label": f"{r['name']} ({r['site'] or 'unknown'})"} for r in rows - ]) - - -@router.get("/options/radios") -async def widget_options_radios(ap_id: int | None = None): - if not ap_id: - return JSONResponse([]) - rows = await _rows( - "SELECT id, band, channel FROM radios WHERE access_point_id = ? ORDER BY band", (ap_id,) - ) - return JSONResponse([ - {"value": str(r["id"]), - "label": f"{r['band']}" + (f" · ch {r['channel']}" if r.get("channel") else "")} - for r in rows - ]) diff --git a/app/api/widgets/__init__.py b/app/api/widgets/__init__.py new file mode 100644 index 0000000..2f1b1a3 --- /dev/null +++ b/app/api/widgets/__init__.py @@ -0,0 +1,13 @@ +""" +pktWiFi — Widget endpoints for pktHub NOC Builder integration. + +Manifest: GET /api/widgets/manifest → list of widget definitions +Views: GET /api/widgets/{id} → server-rendered HTML page (iframe target) +Options: GET /api/widgets/options/* → JSON [{value,label}] for dynamic param pickers +""" +from __future__ import annotations + +from app.api.widgets._common import router # noqa: F401 + +# Importing these registers their routes on the shared router. +from app.api.widgets import manifest, fleet, clients, rf, options # noqa: E402,F401 diff --git a/app/api/widgets/_common.py b/app/api/widgets/_common.py new file mode 100644 index 0000000..0f9e9ca --- /dev/null +++ b/app/api/widgets/_common.py @@ -0,0 +1,278 @@ +""" +Shared by every widget: the router, the page shell and states, formatting, tiles, bars and the line chart, and the read helper. +""" +from __future__ import annotations + +import html +import logging +from contextvars import ContextVar +from datetime import datetime, timedelta, timezone + +import aiosqlite +from fastapi import APIRouter, Depends, Request + +from app.config import get_settings +from app.dependencies import require_suite_token + + +log = logging.getLogger("pktwifi.api.widgets") + + +# ── Refresh interval ────────────────────────────────────────────────────────── +# pktHub's Settings → NOC → "Widget refresh" governs how often a tile reloads +# itself. It arrives as ?refresh= on the widget URL; captured here as a +# router dependency so the ~150 view functions need no signature change. +_REFRESH: ContextVar = ContextVar("widget_refresh", default=30) + + +async def _capture_refresh(request: Request) -> None: + raw = request.query_params.get("refresh") + try: + _REFRESH.set(max(5, min(int(raw), 3600)) if raw else 30) + except (TypeError, ValueError): + _REFRESH.set(30) + + +# These views are embedded as unauthenticated iframes by pktHub's NOC Builder, +# so they can't require a login session — but they do render internal access +# point, client and alert data, so every route on this router requires a valid +# X-Suite-Token (the trusted-proxy secret pktHub already sends on every +# proxied request). +router = APIRouter(dependencies=[Depends(_capture_refresh), Depends(require_suite_token)]) +_s = get_settings() +_DB = _s.db_path + + +# ── Widget states ────────────────────────────────────────────────────────────── +# A blank tile on a wallboard reads as "all quiet", so the three reasons a widget +# can show nothing must look different from each other: +# empty — the query ran and there genuinely is nothing +# cfg — the widget needs a param chosen in the NOC editor before it can run +# err — the query failed; this must never be mistaken for "nothing to report" +# Query helpers record failures here rather than swallowing them; _page() renders +# the error state instead of whatever half-built body the caller produced. The +# ContextVar is per-request: each request runs in its own task context. +_WIDGET_ERR: ContextVar = ContextVar("widget_err", default=None) + + +def _note_err(exc: BaseException) -> None: + # The page is shown on an unauthenticated display URL, and a database error + # names tables and file paths: the full text goes to the log, the page gets + # only the kind of failure. + log.warning("widget query failed: %s: %s", type(exc).__name__, exc) + _WIDGET_ERR.set(type(exc).__name__) + + +def _state(kind: str, msg: str, sub: str = "") -> str: + icon = {"empty": "○", "cfg": "⚙", "err": "⚠"}.get(kind, "○") + sub_html = f'
{html.escape(str(sub))}
' if sub else "" + return (f'
{icon}
' + f'
{html.escape(str(msg))}
{sub_html}
') + + +def _empty(msg: str) -> str: + return _state("empty", msg) + + +def _needs(msg: str) -> str: + """The widget is fine — it is waiting on a filter the NOC editor must set.""" + return _state("cfg", msg, "Select it in the widget's Filters panel") + + +# ── Shared page shell ─────────────────────────────────────────────────────────── +def _page(title: str, body: str) -> str: + # Widget titles carry device/metric/subnet names chosen in the NOC editor + # and read back from device data, and these pages render on an + # unauthenticated display URL — escape before interpolating. + title = html.escape(str(title)) + # A failed query leaves a body saying "nothing here" — which is a lie. + _err = _WIDGET_ERR.get() + if _err: + body = _state("err", "Widget unavailable", _err) + return f""" + +{title} + + + +
{title}
+
{body}
+""" + + +def _status_badge(status: str) -> str: + s = (status or "").lower() + if s in ("online", "ok"): + return '{}'.format(html.escape(s.upper())) + if s in ("offline", "error"): + return '{}'.format(html.escape(s.upper())) + return f'{html.escape((status or "UNKNOWN").upper())}' + + +# ── Time window ───────────────────────────────────────────────────────────────── +def _since(hours: int) -> str: + return (datetime.now(timezone.utc) - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") + + +# ── AP lookup ─────────────────────────────────────────────────────────────────── +async def _ap_name(ap_id: int) -> str | None: + """None when the AP is gone. A NOC screen outlives the estate it was built + against, so a widget pinned to a decommissioned AP has to say so rather than + render an empty frame the wall-watcher reads as 'all quiet'.""" + try: + async with aiosqlite.connect(_DB) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT name FROM access_points WHERE id=?", (ap_id,)) as cur: + row = await cur.fetchone() + return row["name"] if row else None + except Exception: + return None + + +def _gone(what: str) -> str: + return _empty(f'{html.escape(what)} no longer exists') + + +# ── Formatting ────────────────────────────────────────────────────────────────── +def _fmt_n(n) -> str: + try: + n = float(n or 0) + except (TypeError, ValueError): + return "—" + for div, suf in ((1_000_000_000, "G"), (1_000_000, "M"), (1_000, "K")): + if abs(n) >= div: + return f"{n / div:.1f}{suf}" + return f"{n:.0f}" if n == int(n) else f"{n:.1f}" + + +def _fmt_ts(ts) -> str: + return str(ts)[:19].replace("T", " ") if ts else "—" + + +def _fmt_uptime(seconds) -> str: + try: + secs = int(seconds or 0) + except (TypeError, ValueError): + return "—" + d, rem = divmod(secs, 86400) + h, rem = divmod(rem, 3600) + return f"{d}d {h}h" if d else f"{h}h {rem // 60}m" + + +# ── Tiles / bars ──────────────────────────────────────────────────────────────── +def _tiles(pairs) -> str: + return '
' + "".join( + f'
{html.escape(str(label))}
' + f'
{html.escape(str(value))}
' + for label, value in pairs + ) + "
" + + +def _bars(rows, color: str = "#fb923c") -> str: + """rows = [(label, numeric_value, display_value)] — scaled to the largest.""" + peak = max((r[1] or 0) for r in rows) if rows else 0 + return "".join( + f'
{html.escape(str(lbl))}
' + f'
' + f'
{html.escape(str(disp))}
' + for lbl, val, disp in rows + ) + + +# ── Inline SVG line chart ─────────────────────────────────────────────────────── +# Server-rendered so the iframe stays dependency-free — pktWiFi ships no charting +# library to these views, and the NOC display must render without network access +# to anything but this app. +_SERIES_COLORS = ("#fb923c", "#60a5fa", "#4ade80", "#f87171", "#a78bfa") + + +def _line_chart(series, fmt=_fmt_n, height: int = 120) -> str: + """series = [(label, [float, ...])] — equal-length samples, oldest first.""" + series = [(lbl, [v for v in vals if v is not None]) for lbl, vals in series] + series = [(lbl, vals) for lbl, vals in series if len(vals) >= 2] + if not series: + return _empty('No samples in window') + + W, H, PAD = 600, height, 4 + lo = min(min(v) for _, v in series) + hi = max(max(v) for _, v in series) + span = (hi - lo) or 1.0 + + def _y(v: float) -> float: + return PAD + (H - 2 * PAD) * (1 - (v - lo) / span) + + paths, legend = [], [] + for i, (lbl, vals) in enumerate(series): + color = _SERIES_COLORS[i % len(_SERIES_COLORS)] + step = W / (len(vals) - 1) + pts = [(j * step, _y(v)) for j, v in enumerate(vals)] + line = "M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in pts) + area = f"{line} L{W:.1f},{H} L0,{H} Z" + paths.append( + f'' + f'' + ) + legend.append( + f'{html.escape(str(lbl))} ' + f'{html.escape(fmt(vals[-1]))}' + ) + + meta = (f'
min {html.escape(fmt(lo))}' + f'max {html.escape(fmt(hi))}' + f'samples {max(len(v) for _, v in series)}
') + return ( + f'
{meta}' + f'{"".join(paths)}' + f'
{"".join(legend)}
' + ) + + +# ── Query helper ────────────────────────────────────────────────────────────── +async def _rows(sql: str, params: tuple = ()) -> list[dict]: + try: + async with aiosqlite.connect(_DB) as db: + db.row_factory = aiosqlite.Row + async with db.execute(sql, params) as cur: + return [dict(r) for r in await cur.fetchall()] + except Exception as exc: + _note_err(exc) + return [] diff --git a/app/api/widgets/clients.py b/app/api/widgets/clients.py new file mode 100644 index 0000000..69d9bc6 --- /dev/null +++ b/app/api/widgets/clients.py @@ -0,0 +1,158 @@ +""" +Client widgets: counts, bands, SSIDs, signal health, events and the trend chart. +""" +from __future__ import annotations + +import html + +import aiosqlite +from fastapi.responses import HTMLResponse + +from app.api.widgets._common import _DB, _ap_name, _bars, _empty, _fmt_n, _fmt_ts, _gone, _line_chart, _needs, _note_err, _page, _rows, _since, router +from app.wifi.freshness import client_is_current, radio_is_current + + +# ── Client Count widget (per-AP, dynamic) ──────────────────────────────────── +@router.get("/client_count", response_class=HTMLResponse, include_in_schema=False) +async def widget_client_count(ap_id: int | None = None): + if not ap_id: + return HTMLResponse(_page("Client Count", _needs('Select an access point'))) + + ap_name = await _ap_name(ap_id) + if ap_name is None: + return HTMLResponse(_page("Client Count", _gone(f"Access point {ap_id}"))) + + bands = [] + try: + async with aiosqlite.connect(_DB) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + f"""SELECT r.band, r.client_count + FROM radios r JOIN access_points ap ON ap.id = r.access_point_id + WHERE r.access_point_id = ? AND {radio_is_current()} ORDER BY r.band""", + (ap_id,), + ) as cur: + bands = [dict(r) for r in await cur.fetchall()] + except Exception as exc: + _note_err(exc) + + total = sum(b["client_count"] or 0 for b in bands) + tiles = "".join( + f'
{html.escape(str(b["band"]))}
{b["client_count"] or 0}
' + for b in bands + ) or _empty('No radios discovered on any access point') + body = ( + f'
{html.escape(str(ap_name))}
' + f'
Total Clients
{total}
' + f'
{tiles}
' + ) + return HTMLResponse(_page("Client Count", body)) + + +# ── Clients by Band widget ──────────────────────────────────────────────────── +@router.get("/clients_by_band", response_class=HTMLResponse, include_in_schema=False) +async def widget_clients_by_band(): + rows = await _rows( + "SELECT COALESCE(NULLIF(band,''),'unknown') AS band, COUNT(*) AS n " + "FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id " + f"WHERE {client_is_current()} GROUP BY band ORDER BY n DESC" + ) + body = _bars([(r["band"], r["n"], str(r["n"])) for r in rows]) \ + if rows else _empty('No clients are currently associated') + return HTMLResponse(_page("Clients by Band", body)) + + +# ── Clients by SSID widget ──────────────────────────────────────────────────── +@router.get("/clients_by_ssid", response_class=HTMLResponse, include_in_schema=False) +async def widget_clients_by_ssid(): + rows = await _rows( + "SELECT COALESCE(NULLIF(ssid,''),'unknown') AS ssid, COUNT(*) AS n " + "FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id " + f"WHERE {client_is_current()} GROUP BY ssid ORDER BY n DESC LIMIT 20" + ) + body = _bars([(r["ssid"], r["n"], str(r["n"])) for r in rows]) \ + if rows else _empty('No clients are currently associated') + return HTMLResponse(_page("Clients by SSID", body)) + + +# ── Client Signal Health widget ─────────────────────────────────────────────── +@router.get("/client_health", response_class=HTMLResponse, include_in_schema=False) +async def widget_client_health(): + rows = await _rows( + f"""SELECT c.hostname, c.mac_address, c.ssid, c.band, c.rssi_dbm, c.snr_db, + c.tx_rate_mbps, ap.name AS ap_name + FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id + WHERE c.rssi_dbm IS NOT NULL AND {client_is_current()} ORDER BY c.rssi_dbm ASC LIMIT 40""" + ) + if rows: + def _sig(rssi) -> str: + r = float(rssi) + if r >= -65: + return 'GOOD' + if r >= -75: + return 'FAIR' + return 'POOR' + + trs = "".join( + f"{html.escape(str(r.get('hostname') or r.get('mac_address') or ''))}" + f"{html.escape(str(r.get('ap_name') or ''))}" + f"{html.escape(str(r.get('ssid') or ''))}" + f"{_sig(r['rssi_dbm'])} {float(r['rssi_dbm']):.0f}" + f"{_fmt_n(r['snr_db']) if r.get('snr_db') is not None else '—'}" + f"{_fmt_n(r['tx_rate_mbps']) if r.get('tx_rate_mbps') is not None else '—'}" + for r in rows + ) + body = ("" + "" + f"{trs}
ClientAPSSIDRSSISNRTX Mbps
") + else: + body = _empty('No client is reporting signal strength') + return HTMLResponse(_page("Client Signal Health", body)) + + +# ── Client Events widget ────────────────────────────────────────────────────── +@router.get("/client_events", response_class=HTMLResponse, include_in_schema=False) +async def widget_client_events(): + rows = await _rows( + """SELECT e.ts, e.mac_address, e.event_type, + f.name AS from_ap, t.name AS to_ap + FROM client_events e + LEFT JOIN access_points f ON f.id = e.from_ap_id + LEFT JOIN access_points t ON t.id = e.to_ap_id + ORDER BY e.ts DESC LIMIT 40""" + ) + if rows: + def _evt(t: str) -> str: + t = (t or "").lower() + if t in ("deauth", "auth_fail"): + return f'{html.escape(t.upper())}' + if t == "roam": + return 'ROAM' + return f'{html.escape(t.upper())}' + + trs = "".join( + f"{html.escape(_fmt_ts(r['ts']))}{html.escape(str(r['mac_address']))}" + f"{_evt(r['event_type'])}" + f"{html.escape(str(r.get('from_ap') or '—'))} → {html.escape(str(r.get('to_ap') or '—'))}" + for r in rows + ) + body = ("" + f"{trs}
TimeClientEventAP
") + else: + body = _empty('No client events') + return HTMLResponse(_page("Client Events", body)) + + +# ── Client Trend widget (chart) ─────────────────────────────────────────────── +@router.get("/client_trend", response_class=HTMLResponse, include_in_schema=False) +async def widget_client_trend(hours: int = 6): + hours = max(1, min(int(hours or 6), 720)) + # Sum across radios per sample bucket — radio_metrics holds one row per radio. + rows = await _rows( + """SELECT substr(ts, 1, 16) AS bucket, SUM(client_count) AS n + FROM radio_metrics WHERE ts >= ? AND client_count IS NOT NULL + GROUP BY bucket ORDER BY bucket ASC LIMIT 2000""", + (_since(hours),), + ) + body = _line_chart([("Clients", [r["n"] for r in rows])]) + return HTMLResponse(_page(f"Client Trend — last {hours}h", body)) diff --git a/app/api/widgets/fleet.py b/app/api/widgets/fleet.py new file mode 100644 index 0000000..07a360f --- /dev/null +++ b/app/api/widgets/fleet.py @@ -0,0 +1,195 @@ +""" +Estate-level widgets: access point status, alerts, summaries, sites, uptime, rogues and collectors. +""" +from __future__ import annotations + +import html + +import aiosqlite +from fastapi.responses import HTMLResponse + +from app.api.widgets._common import _DB, _bars, _empty, _fmt_ts, _fmt_uptime, _note_err, _page, _rows, _status_badge, _tiles, router +from app.wifi.freshness import client_is_current, radio_is_current + + +# ── AP Status widget ────────────────────────────────────────────────────────── +@router.get("/ap_status", response_class=HTMLResponse, include_in_schema=False) +async def widget_ap_status(): + rows = [] + try: + async with aiosqlite.connect(_DB) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + f"""SELECT ap.id, ap.name, ap.site, ap.status, + COALESCE((SELECT SUM(r.client_count) FROM radios r + WHERE r.access_point_id = ap.id AND {radio_is_current()}), 0) AS clients + FROM access_points ap + ORDER BY CASE ap.status WHEN 'offline' THEN 0 WHEN 'unknown' THEN 1 ELSE 2 END, ap.name""" + ) as cur: + rows = [dict(r) for r in await cur.fetchall()] + except Exception as exc: + _note_err(exc) + + if rows: + trs = "".join( + f"{html.escape(str(r['name']))}{html.escape(str(r.get('site') or ''))}" + f"{_status_badge(r['status'])}{r['clients']}" + for r in rows + ) + body = ( + "" + f"{trs}
Access PointSiteStatusClients
" + ) + else: + body = _empty('No access points') + return HTMLResponse(_page("AP Status", body)) + + +# ── Active Alerts widget ────────────────────────────────────────────────────── +@router.get("/active_alerts", response_class=HTMLResponse, include_in_schema=False) +async def widget_active_alerts(): + rows = [] + try: + async with aiosqlite.connect(_DB) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + """SELECT ae.severity, ae.message, ae.created_at, ap.name AS ap_name + FROM alert_events ae LEFT JOIN access_points ap ON ap.id = ae.access_point_id + WHERE ae.active = 1 AND ae.acked = 0 + ORDER BY ae.created_at DESC LIMIT 40""" + ) as cur: + rows = [dict(r) for r in await cur.fetchall()] + except Exception as exc: + _note_err(exc) + + if rows: + trs = "".join( + f"{_status_badge('offline' if r['severity'] == 'critical' else 'unknown')}" + f"{html.escape(str(r.get('ap_name') or ''))}{html.escape(str(r['message']))}" + f"{html.escape(str(r['created_at'])[:19].replace('T',' '))}" + for r in rows + ) + body = ( + "" + f"{trs}
SeverityAPMessageFired
" + ) + else: + body = _empty('No active alerts') + return HTMLResponse(_page("Active Alerts", body)) + + +# ── WiFi Summary widget ─────────────────────────────────────────────────────── +@router.get("/wifi_summary", response_class=HTMLResponse, include_in_schema=False) +async def widget_wifi_summary(): + ap = await _rows( + """SELECT COUNT(*) AS total, + SUM(CASE WHEN status='online' THEN 1 ELSE 0 END) AS online, + SUM(CASE WHEN status='offline' THEN 1 ELSE 0 END) AS offline, + SUM(CASE WHEN is_rogue=1 THEN 1 ELSE 0 END) AS rogue + FROM access_points""" + ) + cli = await _rows( + f"""SELECT COUNT(*) AS clients + FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id + WHERE {client_is_current()}""" + ) + a = ap[0] if ap else {} + body = _tiles([ + ("APs", a.get("total") or 0), + ("Online", a.get("online") or 0), + ("Offline", a.get("offline") or 0), + ("Rogue", a.get("rogue") or 0), + ("Clients", (cli[0]["clients"] if cli else 0) or 0), + ]) + return HTMLResponse(_page("WiFi Summary", body)) + + +# ── Alert Summary widget ────────────────────────────────────────────────────── +@router.get("/alert_summary", response_class=HTMLResponse, include_in_schema=False) +async def widget_alert_summary(): + rows = await _rows( + "SELECT LOWER(severity) AS sev, COUNT(*) AS n FROM alert_events " + "WHERE active = 1 AND acked = 0 GROUP BY sev" + ) + counts = {r["sev"]: r["n"] for r in rows} + body = _tiles([ + ("Active", sum(counts.values())), + ("Critical", counts.get("critical", 0)), + ("Warning", counts.get("warning", 0)), + ("Info", counts.get("info", 0)), + ]) + return HTMLResponse(_page("Alert Summary", body)) + + +# ── APs by Site widget ──────────────────────────────────────────────────────── +@router.get("/aps_by_site", response_class=HTMLResponse, include_in_schema=False) +async def widget_aps_by_site(): + rows = await _rows( + """SELECT CASE WHEN site IS NULL OR site = '' THEN 'Unassigned' ELSE site END AS site, + COUNT(*) AS total, + SUM(CASE WHEN status='offline' THEN 1 ELSE 0 END) AS offline + FROM access_points GROUP BY site ORDER BY total DESC LIMIT 20""" + ) + body = _bars([ + (r["site"], r["total"], f"{r['total']}" + (f" · {r['offline']}↓" if r["offline"] else "")) + for r in rows + ]) if rows else _empty('No access points') + return HTMLResponse(_page("APs by Site", body)) + + +# ── AP Uptime widget ────────────────────────────────────────────────────────── +@router.get("/ap_uptime", response_class=HTMLResponse, include_in_schema=False) +async def widget_ap_uptime(): + rows = await _rows( + "SELECT name, uptime_seconds FROM access_points " + "WHERE uptime_seconds IS NOT NULL ORDER BY uptime_seconds ASC LIMIT 30" + ) + body = _bars([ + (r["name"], float(r["uptime_seconds"] or 0), _fmt_uptime(r["uptime_seconds"])) + for r in rows + ], color="#60a5fa") if rows else _empty('No access point is reporting uptime') + return HTMLResponse(_page("AP Uptime", body)) + + +# ── Rogue APs widget ────────────────────────────────────────────────────────── +@router.get("/rogue_aps", response_class=HTMLResponse, include_in_schema=False) +async def widget_rogue_aps(): + rows = await _rows( + "SELECT name, mac_address, site, vendor, last_seen FROM access_points " + "WHERE is_rogue = 1 ORDER BY last_seen DESC LIMIT 40" + ) + if rows: + trs = "".join( + f"{html.escape(str(r['name']))}{html.escape(str(r.get('mac_address') or ''))}" + f"{html.escape(str(r.get('site') or ''))}{html.escape(str(r.get('vendor') or ''))}" + f"{html.escape(_fmt_ts(r.get('last_seen')))}" + for r in rows + ) + body = ("" + f"{trs}
NameMACSiteVendorLast Seen
") + else: + body = _empty('No rogue APs detected') + return HTMLResponse(_page("Rogue APs", body)) + + +# ── Collector Status widget ─────────────────────────────────────────────────── +@router.get("/collector_status", response_class=HTMLResponse, include_in_schema=False) +async def widget_collector_status(): + rows = await _rows( + "SELECT name, collector_type, enabled, status, last_poll_at, last_error " + "FROM collectors ORDER BY name" + ) + if rows: + trs = "".join( + f"{html.escape(str(r['name']))}{html.escape(str(r.get('collector_type') or ''))}" + f"{_status_badge(r.get('status') if r.get('enabled') else 'disabled')}" + f"{html.escape(_fmt_ts(r.get('last_poll_at')))}" + f"{html.escape(str(r.get('last_error') or ''))[:60]}" + for r in rows + ) + body = ("" + "" + f"{trs}
CollectorTypeStatusLast PollError
") + else: + body = _empty('No collectors') + return HTMLResponse(_page("Collector Status", body)) diff --git a/app/api/widgets/manifest.py b/app/api/widgets/manifest.py new file mode 100644 index 0000000..8c71325 --- /dev/null +++ b/app/api/widgets/manifest.py @@ -0,0 +1,190 @@ +""" +GET /api/widgets/manifest: the list of widget definitions pktHub's NOC Builder offers. +""" +from __future__ import annotations + +from app.api.widgets._common import router + + +# ── Manifest ────────────────────────────────────────────────────────────────── +# `category` groups these in pktHub's NOC library picker. Every data surface the +# app renders in its own UI should have an entry here — the NOC builder can only +# offer what this list declares. +_AP_PARAM = { + "key": "ap_id", "label": "Access Point", "type": "select", + "options_path": "/api/widgets/options/access_points", +} + + +_WINDOW_PARAM = { + "key": "hours", "label": "Window", "type": "select", + "options": [{"value": "1", "label": "1 hour"}, {"value": "6", "label": "6 hours"}, + {"value": "24", "label": "24 hours"}, {"value": "168", "label": "7 days"}], +} + + +MANIFEST = [ + # ── Overview ────────────────────────────────────────────────────────────── + { + "id": "wifi_summary", "title": "WiFi Summary", "category": "Overview", + "description": "Access point, client and rogue counts across the estate", + "view_path": "/api/widgets/wifi_summary", + "default_w": 560, "default_h": 200, "min_w": 300, "min_h": 150, + }, + { + "id": "alert_summary", "title": "Alert Summary", "category": "Overview", + "description": "Active alert counts by severity", + "view_path": "/api/widgets/alert_summary", + "default_w": 420, "default_h": 200, "min_w": 260, "min_h": 150, + }, + { + "id": "aps_by_site", "title": "APs by Site", "category": "Overview", + "description": "Access point count and offline count per site", + "view_path": "/api/widgets/aps_by_site", + "default_w": 480, "default_h": 320, "min_w": 280, "min_h": 200, + }, + + # ── Access Points ───────────────────────────────────────────────────────── + { + "id": "ap_status", "title": "AP Status", "category": "Access Points", + "description": "All access points with site, status, and connected client count", + "view_path": "/api/widgets/ap_status", + "default_w": 640, "default_h": 380, "min_w": 340, "min_h": 220, + }, + { + "id": "ap_uptime", "title": "AP Uptime", "category": "Access Points", + "description": "Reported uptime per access point, least stable first", + "view_path": "/api/widgets/ap_uptime", + "default_w": 540, "default_h": 340, "min_w": 300, "min_h": 200, + }, + { + "id": "rogue_aps", "title": "Rogue APs", "category": "Access Points", + "description": "Access points flagged as rogue", + "view_path": "/api/widgets/rogue_aps", + "default_w": 620, "default_h": 320, "min_w": 320, "min_h": 180, + }, + + # ── Radios ──────────────────────────────────────────────────────────────── + { + "id": "radio_overview", "title": "Radio Overview", "category": "Radios", + "description": "Per-radio band, channel, width, power, utilization and noise", + "view_path": "/api/widgets/radio_overview", + "default_w": 780, "default_h": 400, "min_w": 380, "min_h": 220, + }, + { + "id": "channel_utilization", "title": "Channel Utilization", "category": "Radios", + "description": "Busiest radios by channel utilization", + "view_path": "/api/widgets/channel_utilization", + "default_w": 540, "default_h": 340, "min_w": 300, "min_h": 200, + }, + { + "id": "noise_floor", "title": "Noise Floor", "category": "Radios", + "description": "Radios with the highest noise floor", + "view_path": "/api/widgets/noise_floor", + "default_w": 540, "default_h": 340, "min_w": 300, "min_h": 200, + }, + + # ── Clients ─────────────────────────────────────────────────────────────── + { + "id": "client_count", "title": "Client Count", "category": "Clients", + "description": "Connected client count by radio band for one access point", + "view_path": "/api/widgets/client_count", + "default_w": 460, "default_h": 300, "min_w": 280, "min_h": 180, + "params": [_AP_PARAM], + }, + { + "id": "clients_by_band", "title": "Clients by Band", "category": "Clients", + "description": "Connected client distribution across radio bands", + "view_path": "/api/widgets/clients_by_band", + "default_w": 440, "default_h": 280, "min_w": 260, "min_h": 170, + }, + { + "id": "clients_by_ssid", "title": "Clients by SSID", "category": "Clients", + "description": "Connected client distribution across SSIDs", + "view_path": "/api/widgets/clients_by_ssid", + "default_w": 480, "default_h": 300, "min_w": 280, "min_h": 180, + }, + { + "id": "client_health", "title": "Client Signal Health", "category": "Clients", + "description": "Clients bucketed by signal strength, weakest listed first", + "view_path": "/api/widgets/client_health", + "default_w": 620, "default_h": 360, "min_w": 320, "min_h": 200, + }, + { + "id": "client_events", "title": "Client Events", "category": "Clients", + "description": "Recent associate, roam, deauth and auth-failure events", + "view_path": "/api/widgets/client_events", + "default_w": 700, "default_h": 360, "min_w": 340, "min_h": 200, + }, + + # ── Trends (charts) ─────────────────────────────────────────────────────── + { + "id": "radio_trend", "title": "Radio Trend", "category": "Trends", + "description": "Utilization, clients and noise over time for one radio", + "view_path": "/api/widgets/radio_trend", + "default_w": 680, "default_h": 320, "min_w": 320, "min_h": 180, + "params": [ + _AP_PARAM, + # {ap_id} is substituted from the widget's own config by pktHub, so the + # radio list reflects whatever bands that AP currently reports. + {"key": "radio_id", "label": "Radio", "type": "select", + "options_path": "/api/widgets/options/radios?ap_id={ap_id}"}, + {"key": "metric", "label": "Metric", "type": "select", + "options": [{"value": "utilization_pct", "label": "Utilization %"}, + {"value": "client_count", "label": "Clients"}, + {"value": "noise_floor_dbm", "label": "Noise floor"}, + {"value": "retry_pct", "label": "Retry %"}, + {"value": "crc_error_pct", "label": "CRC error %"}, + {"value": "tx_power_dbm", "label": "TX power"}]}, + _WINDOW_PARAM, + ], + }, + { + "id": "client_trend", "title": "Client Trend", "category": "Trends", + "description": "Total connected clients over time", + "view_path": "/api/widgets/client_trend", + "default_w": 620, "default_h": 300, "min_w": 300, "min_h": 170, + "params": [_WINDOW_PARAM], + }, + + { + "id": "airtime_trend", "title": "Airtime by Band", "category": "Trends", + "description": "Average channel utilization per band over time, across all radios", + "view_path": "/api/widgets/airtime_trend", + "default_w": 680, "default_h": 320, "min_w": 320, "min_h": 180, + "params": [_WINDOW_PARAM], + }, + { + "id": "rf_spectrum", "title": "RF Spectrum", "category": "Radios", + "description": "Where each radio sits in the 2.4, 5 and 6 GHz bands; height is channel utilization", + "view_path": "/api/widgets/rf_spectrum", + "default_w": 760, "default_h": 420, "min_w": 400, "min_h": 260, + }, + { + "id": "signal_scope", "title": "Signal Scope", "category": "Clients", + "description": "Every connected client by the access point it is on; range is signal strength", + "view_path": "/api/widgets/signal_scope", + "default_w": 520, "default_h": 460, "min_w": 300, "min_h": 300, + }, + + # ── Alerts ──────────────────────────────────────────────────────────────── + { + "id": "active_alerts", "title": "Active Alerts", "category": "Alerts", + "description": "Unresolved WiFi alert events", + "view_path": "/api/widgets/active_alerts", + "default_w": 640, "default_h": 360, "min_w": 320, "min_h": 200, + }, + + # ── Collectors ──────────────────────────────────────────────────────────── + { + "id": "collector_status", "title": "Collector Status", "category": "Collectors", + "description": "Controller/collector health and last successful poll", + "view_path": "/api/widgets/collector_status", + "default_w": 620, "default_h": 300, "min_w": 320, "min_h": 180, + }, +] + + +@router.get("/manifest") +async def widget_manifest(): + return MANIFEST diff --git a/app/api/widgets/options.py b/app/api/widgets/options.py new file mode 100644 index 0000000..758afa5 --- /dev/null +++ b/app/api/widgets/options.py @@ -0,0 +1,34 @@ +""" +GET /api/widgets/options/*: JSON choices for the dynamic parameter pickers. +""" +from __future__ import annotations + +from fastapi.responses import JSONResponse + +from app.api.widgets._common import _rows, router + + +# ── Param option pickers ────────────────────────────────────────────────────── +# Every picker reads live state rather than a static list, so an AP or radio +# added or removed after a NOC screen was built shows up (or drops out) the next +# time the editor opens the param — no manifest edit and no pktHub change needed. +@router.get("/options/access_points") +async def widget_options_access_points(): + rows = await _rows("SELECT id, name, site FROM access_points ORDER BY name") + return JSONResponse([ + {"value": str(r["id"]), "label": f"{r['name']} ({r['site'] or 'unknown'})"} for r in rows + ]) + + +@router.get("/options/radios") +async def widget_options_radios(ap_id: int | None = None): + if not ap_id: + return JSONResponse([]) + rows = await _rows( + "SELECT id, band, channel FROM radios WHERE access_point_id = ? ORDER BY band", (ap_id,) + ) + return JSONResponse([ + {"value": str(r["id"]), + "label": f"{r['band']}" + (f" · ch {r['channel']}" if r.get("channel") else "")} + for r in rows + ]) diff --git a/app/api/widgets/rf.py b/app/api/widgets/rf.py new file mode 100644 index 0000000..f6e95ab --- /dev/null +++ b/app/api/widgets/rf.py @@ -0,0 +1,258 @@ +""" +Radio and RF widgets: overview, channel utilisation, noise floor, trends, spectrum and signal scope. +""" +from __future__ import annotations + +import html + +from fastapi.responses import HTMLResponse + +from app.api.widgets._common import _ap_name, _bars, _empty, _fmt_n, _gone, _line_chart, _needs, _page, _rows, _since, router +from app.wifi.freshness import client_is_current, radio_is_current + + +# ── Radio Overview widget ───────────────────────────────────────────────────── +@router.get("/radio_overview", response_class=HTMLResponse, include_in_schema=False) +async def widget_radio_overview(): + rows = await _rows( + f"""SELECT ap.name AS ap_name, r.band, r.channel, r.channel_width_mhz, + r.tx_power_dbm, r.utilization_pct, r.noise_floor_dbm, r.client_count + FROM radios r JOIN access_points ap ON ap.id = r.access_point_id + WHERE {radio_is_current()} + ORDER BY r.utilization_pct DESC, ap.name LIMIT 60""" + ) + if rows: + trs = "".join( + f"{html.escape(str(r['ap_name']))}{html.escape(str(r.get('band') or ''))}" + f"{r.get('channel') if r.get('channel') is not None else '—'}" + f"{('/' + str(r['channel_width_mhz'])) if r.get('channel_width_mhz') else ''}" + f"{_fmt_n(r['tx_power_dbm']) + ' dBm' if r.get('tx_power_dbm') is not None else '—'}" + f"{_fmt_n(r['utilization_pct']) + '%' if r.get('utilization_pct') is not None else '—'}" + f"{_fmt_n(r['noise_floor_dbm']) + ' dBm' if r.get('noise_floor_dbm') is not None else '—'}" + f"{r.get('client_count') or 0}" + for r in rows + ) + body = ("" + "" + f"{trs}
APBandCh/WTXUtilNoiseClients
") + else: + body = _empty('No radios discovered on any access point') + return HTMLResponse(_page("Radio Overview", body)) + + +# ── Channel Utilization widget ──────────────────────────────────────────────── +@router.get("/channel_utilization", response_class=HTMLResponse, include_in_schema=False) +async def widget_channel_utilization(): + rows = await _rows( + f"""SELECT ap.name AS ap_name, r.band, r.channel, r.utilization_pct + FROM radios r JOIN access_points ap ON ap.id = r.access_point_id + WHERE r.utilization_pct IS NOT NULL AND {radio_is_current()} + ORDER BY r.utilization_pct DESC LIMIT 25""" + ) + body = _bars([ + (f"{r['ap_name']} · {r.get('band') or ''}{(' ch' + str(r['channel'])) if r.get('channel') else ''}", + float(r["utilization_pct"] or 0), f"{float(r['utilization_pct'] or 0):.0f}%") + for r in rows + ]) if rows else _empty('No radio is reporting channel utilization') + return HTMLResponse(_page("Channel Utilization", body)) + + +# ── Noise Floor widget ──────────────────────────────────────────────────────── +@router.get("/noise_floor", response_class=HTMLResponse, include_in_schema=False) +async def widget_noise_floor(): + # Noise floor is negative dBm — closer to zero is worse, so rank descending. + rows = await _rows( + f"""SELECT ap.name AS ap_name, r.band, r.noise_floor_dbm + FROM radios r JOIN access_points ap ON ap.id = r.access_point_id + WHERE r.noise_floor_dbm IS NOT NULL AND {radio_is_current()} + ORDER BY r.noise_floor_dbm DESC LIMIT 25""" + ) + if rows: + # Bars scale on distance from a -100 dBm floor so the worst radio is longest. + body = _bars([ + (f"{r['ap_name']} · {r.get('band') or ''}", + max(0.0, 100.0 + float(r["noise_floor_dbm"])), f"{float(r['noise_floor_dbm']):.0f} dBm") + for r in rows + ], color="#f87171") + else: + body = _empty('No radio is reporting a noise floor') + return HTMLResponse(_page("Noise Floor", body)) + + +# ── Radio Trend widget (chart) ──────────────────────────────────────────────── +# Metric name from the widget's saved config -> the column it reads. The column +# put into the SELECT is always one of these literals, never the request's text. +_RADIO_METRICS = { + "utilization_pct": "utilization_pct", + "client_count": "client_count", + "noise_floor_dbm": "noise_floor_dbm", + "retry_pct": "retry_pct", + "crc_error_pct": "crc_error_pct", + "tx_power_dbm": "tx_power_dbm", +} + + +@router.get("/radio_trend", response_class=HTMLResponse, include_in_schema=False) +async def widget_radio_trend( + ap_id: int | None = None, radio_id: int | None = None, + metric: str = "utilization_pct", hours: int = 6, +): + if not radio_id: + return HTMLResponse(_page("Radio Trend", _needs('Select an access point and radio'))) + if ap_id and await _ap_name(ap_id) is None: + return HTMLResponse(_page("Radio Trend", _gone(f"Access point {ap_id}"))) + # The column is interpolated into the SELECT, so it comes from the table + # above rather than from the request; an unknown metric falls back to the default. + column = _RADIO_METRICS.get(metric, "utilization_pct") + + hours = max(1, min(int(hours or 6), 720)) + rows = await _rows( + f"SELECT ts, {column} AS v FROM radio_metrics " + "WHERE radio_id = ? AND ts >= ? AND " + f"{column} IS NOT NULL ORDER BY ts ASC LIMIT 2000", + (radio_id, _since(hours)), + ) + if not rows: + return HTMLResponse(_page("Radio Trend", _empty('No samples in window'))) + + band = await _rows("SELECT band FROM radios WHERE id = ?", (radio_id,)) + label = f"{band[0]['band']} {column}" if band else column + body = _line_chart([(label, [r["v"] for r in rows])]) + return HTMLResponse(_page(f"{label} — last {hours}h", body)) + + +# ── Airtime by Band widget (chart) ──────────────────────────────────────────── +@router.get("/airtime_trend", response_class=HTMLResponse, include_in_schema=False) +async def widget_airtime_trend(hours: int = 6): + hours = max(1, min(int(hours or 6), 720)) + bucket = max(60, (hours * 3600) // 120) + rows = await _rows( + """SELECT (CAST(strftime('%s', m.ts) AS INTEGER) / ?) AS b, r.band AS band, + AVG(m.utilization_pct) AS util + FROM radio_metrics m JOIN radios r ON r.id = m.radio_id + WHERE m.ts >= ? AND m.utilization_pct IS NOT NULL + AND r.band IN ('2.4GHz','5GHz','6GHz') + GROUP BY b, r.band ORDER BY b""", + (bucket, _since(hours)), + ) + if not rows: + return HTMLResponse(_page("Airtime by Band", _empty('No radio reported utilization in this window'))) + buckets = sorted({r["b"] for r in rows}) + series = [] + for band in ("2.4GHz", "5GHz", "6GHz"): + have = {r["b"]: r["util"] for r in rows if r["band"] == band} + if not have: + continue + # Carry the last reading across a gap (and the first one back to the + # start) so every band spans the same buckets. + first = have[min(have)] + vals, last = [], first + for b in buckets: + last = have.get(b, last) + vals.append(float(last)) + series.append((band, vals)) + body = _line_chart(series, fmt=lambda v: f"{v:.0f}%") + return HTMLResponse(_page(f"Airtime by Band — last {hours}h", body)) + + +# ── RF Spectrum widget ──────────────────────────────────────────────────────── +@router.get("/rf_spectrum", response_class=HTMLResponse, include_in_schema=False) +async def widget_rf_spectrum(): + from app.wifi.rf import BANDS, band_axis, occupied_span + rows = await _rows( + f"""SELECT ap.name AS ap_name, r.band, r.channel, r.channel_width_mhz, r.utilization_pct + FROM radios r JOIN access_points ap ON ap.id = r.access_point_id + WHERE r.band IN ('2.4GHz','5GHz','6GHz') AND {radio_is_current()}""" + ) + placed = [] + for r in rows: + span = occupied_span(r["band"], r["channel"], r["channel_width_mhz"]) + if span: + placed.append({**r, **span}) + if not placed: + return HTMLResponse(_page("RF Spectrum", _empty('No radio has a usable channel'))) + + colors = {"2.4GHz": "#fb923c", "5GHz": "#60a5fa", "6GHz": "#a78bfa"} + W, LANE, GAP = 600, 96, 22 + lanes, y0 = [], 0 + for band in BANDS: + mine = [p for p in placed if p["band"] == band] + if not mine: + continue + ax = band_axis(band) + lo, hi = ax["lo_mhz"], ax["hi_mhz"] + X = lambda mhz: (mhz - lo) / (hi - lo) * W + base = y0 + LANE + parts = [f'{html.escape(band)}', + f''] + for t in ax["ticks"]: + if t["major"]: + x = X(t["mhz"]) + parts.append(f'' + f'{t["channel"]}') + for p in mine: + util = p["utilization_pct"] + h = max(4.0, (float(util) if util is not None else 8.0) / 100 * (LANE - 18)) + x, w = X(p["lo_mhz"]), max(2.0, X(p["hi_mhz"]) - X(p["lo_mhz"])) + dash = ' stroke-dasharray="3 2" stroke="#e2e8f0" stroke-width="1"' \ + if (p["approximate"] or not p["width_reported"] or util is None) else "" + tip = f'{p["ap_name"]} · ch {p["channel"]} · ' + (f'{float(util):.0f}%' if util is not None else 'no utilization') + parts.append(f'{html.escape(tip)}') + lanes.append("".join(parts)) + y0 += LANE + GAP + body = (f'' + f'{"".join(lanes)}' + '
height = channel utilization · ' + 'dashed = width not reported, position approximate or no utilization
') + return HTMLResponse(_page("RF Spectrum", body)) + + +# ── Signal Scope widget ─────────────────────────────────────────────────────── +@router.get("/signal_scope", response_class=HTMLResponse, include_in_schema=False) +async def widget_signal_scope(): + import math + import zlib + from app.wifi.rf import SIGNAL_FAIR_DBM, SIGNAL_GOOD_DBM + rows = await _rows( + f"""SELECT ap.id AS ap_id, ap.name AS ap_name, c.rssi_dbm, c.mac_address + FROM wifi_clients c JOIN access_points ap ON ap.id = c.access_point_id + WHERE c.rssi_dbm IS NOT NULL AND {client_is_current()}""" + ) + if not rows: + return HTMLResponse(_page("Signal Scope", _empty('No client is reporting a signal'))) + by_ap: dict[int, list[dict]] = {} + for r in rows: + by_ap.setdefault(r["ap_id"], []).append(r) + ranked = sorted(by_ap.values(), key=lambda m: (-len(m), m[0]["ap_name"]))[:16] + + CX = CY = 200 + R_CORE, R_MAX, STRONG, WEAK = 20, 150, -30, -95 + rng = lambda v: R_CORE + (R_MAX - R_CORE) * (STRONG - max(WEAK, min(STRONG, v))) / (STRONG - WEAK) + n = len(ranked) + parts = [] + for dbm, lbl in ((SIGNAL_GOOD_DBM, "good"), (SIGNAL_FAIR_DBM, "fair")): + parts.append(f'{dbm} dBm') + for i, members in enumerate(ranked): + a0, a1 = 360 * i / n, 360 * (i + 1) / n + mid = math.radians((a0 + a1) / 2) + sx, sy = CX + R_MAX * math.sin(math.radians(a0)), CY - R_MAX * math.cos(math.radians(a0)) + parts.append(f'') + lx, ly = CX + (R_MAX + 14) * math.sin(mid), CY - (R_MAX + 14) * math.cos(mid) + name = str(members[0]["ap_name"]) + parts.append(f'' + f'{html.escape(name[:14])} · {len(members)}') + for j, c in enumerate(sorted(members, key=lambda m: m["rssi_dbm"])[:120]): + # Where a client sits around its sector means nothing; it is spread + # out only so the dots do not stack. + frac = ((zlib.crc32(f'{c["mac_address"]}|{i}'.encode()) % 1000) / 1000) * 0.8 + 0.1 + ang = math.radians(a0 + (a1 - a0) * frac) + r = rng(float(c["rssi_dbm"])) + col = ("#4ade80" if c["rssi_dbm"] >= SIGNAL_GOOD_DBM + else "#fbbf24" if c["rssi_dbm"] >= SIGNAL_FAIR_DBM else "#f87171") + parts.append(f'') + body = ('{"".join(parts)}') + return HTMLResponse(_page("Signal Scope", body)) diff --git a/app/auth/saml.py b/app/auth/saml.py index 12d3a2e..3f64482 100644 --- a/app/auth/saml.py +++ b/app/auth/saml.py @@ -12,6 +12,40 @@ from onelogin.saml2.auth import OneLogin_Saml2_Auth +def parse_idp_metadata(xml: str) -> dict: + """Entity ID, SSO URL and signing certificate from an IdP's metadata XML, + for the Auth tab's "paste your IdP's metadata" box. + + The XML is untrusted input, so it goes through python3-saml's parser, which + refuses DTDs and entity declarations outright. That is why this is done on + the server and not with the browser's DOMParser. + + Raises ValueError with a message that is fit to show to the person pasting. + """ + from onelogin.saml2.constants import OneLogin_Saml2_Constants as C + from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser as Parser + + try: + idp = Parser.parse(xml, required_sso_binding=C.BINDING_HTTP_REDIRECT).get("idp", {}) + if not idp.get("singleSignOnService"): + # No redirect endpoint: take a POST one rather than none. + post = Parser.parse(xml, required_sso_binding=C.BINDING_HTTP_POST).get("idp", {}) + idp = {**idp, "singleSignOnService": post.get("singleSignOnService", {})} + except Exception: + raise ValueError("Invalid XML — check the metadata and try again.") + + multi = idp.get("x509certMulti") or {} + cert = idp.get("x509cert") or next(iter(multi.get("signing") or multi.get("encryption") or []), "") + result = { + "entity_id": idp.get("entityId", ""), + "sso_url": (idp.get("singleSignOnService") or {}).get("url", ""), + "cert": cert, + } + if not any(result.values()): + raise ValueError("No SAML IdP data found in this XML.") + return result + + def _decode(raw_kv: dict, key: str, default="") -> str: raw = raw_kv.get(key, _json.dumps(default)) try: diff --git a/app/backup.py b/app/backup.py index ab97028..c75c592 100644 --- a/app/backup.py +++ b/app/backup.py @@ -101,6 +101,7 @@ def run_backup_sync(db_path: str) -> dict: finally: verify.close() except Exception as e: + log.warning(f"Backup {dest.name}: integrity check could not run: {e}") check = f"verification failed: {e}" if check == "ok": diff --git a/app/dependencies.py b/app/dependencies.py index e0f4809..3d8a2e0 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -94,7 +94,7 @@ def cookie_secure(request: Request) -> bool: async def require_suite_token(request: Request) -> None: """ Gate for endpoints that are embedded unauthenticated (e.g. pktHub NOC - Builder widget iframes, see app/api/widgets.py) and therefore can't go + Builder widget iframes, see app/api/widgets/) and therefore can't go through the normal login/session flow, but still must not be reachable by literally anyone on the network. Requires a valid X-Suite-Token — the same trusted-proxy secret used by get_current_user above — and diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..94baf6a --- /dev/null +++ b/app/errors.py @@ -0,0 +1,28 @@ +""" +Exception text that is safe to store or show. + +An httpx error carries the URL it was calling, and a URL can carry credentials: +`user:pass@host`, or a key in the query string. Error text is saved on the +collector row and returned to the browser, so it passes through here first. +The scheme, host and path stay — they are what an admin needs to tell which +call failed — but userinfo and query values are replaced. +""" +from __future__ import annotations + +import re + +_USERINFO = re.compile(r"(https?://)[^/\s@'\"]+@") +_QUERY = re.compile(r"(https?://[^\s?#'\"]+)\?[^\s'\"]*") + + +def redact(text: str) -> str: + return _QUERY.sub(r"\1?…", _USERINFO.sub(r"\1***@", text)) + + +def describe_exception(exc: BaseException, *, with_type: bool = False) -> str: + """Redacted message of an exception, falling back to its type name when it + has none (httpx.ConnectTimeout, for one, prints as an empty string).""" + text = redact(str(exc)) + if not text: + return type(exc).__name__ + return f"{type(exc).__name__}: {text}" if with_type else text diff --git a/app/integrations/suite_client.py b/app/integrations/suite_client.py index 704b438..b97f137 100644 --- a/app/integrations/suite_client.py +++ b/app/integrations/suite_client.py @@ -22,6 +22,8 @@ import httpx +from app.errors import describe_exception, redact + log = logging.getLogger("pktwifi.integrations") @@ -85,10 +87,10 @@ async def health_check(self) -> tuple[bool, str]: # as a generic connection problem. Name it — the fix is a checkbox # on this form, and nothing else in the message points at it. if "certificate" in str(exc).lower() or "ssl" in str(exc).lower(): - return False, (f"TLS certificate was not trusted: {exc} — if this app uses a " + return False, (f"TLS certificate was not trusted: {redact(str(exc))} — if this app uses a " "self-signed certificate, untick 'Verify TLS certificate'") - return False, f"could not connect to host/port: {exc}" + return False, f"could not connect to host/port: {redact(str(exc))}" except httpx.TimeoutException: return False, "connection timed out" except Exception as exc: - return False, str(exc) + return False, describe_exception(exc) diff --git a/app/sqlutil.py b/app/sqlutil.py new file mode 100644 index 0000000..c988dbb --- /dev/null +++ b/app/sqlutil.py @@ -0,0 +1,35 @@ +""" +Builds the WHERE clause of a filtered query. + +Every list endpoint here assembles one from optional filters: each filter adds +a fixed SQL fragment with `?` placeholders and the values to bind to them. Doing +that by hand means keeping two lists in step; `Where` keeps them together and +refuses a fragment whose placeholders do not match its values. Only fragments +written in the source may be added — a value from a request goes in `params`, +never into the fragment. +""" +from __future__ import annotations + +from typing import Any + + +class Where: + def __init__(self) -> None: + self._clauses: list[str] = [] + self.params: list[Any] = [] + + def add(self, clause: str, *params: Any) -> None: + if clause.count("?") != len(params): + raise ValueError(f"{clause!r} has {clause.count('?')} placeholder(s) but {len(params)} value(s)") + self._clauses.append(clause) + self.params.extend(params) + + @property + def sql(self) -> str: + """`WHERE a AND b`, or an empty string when nothing was added.""" + return ("WHERE " + " AND ".join(self._clauses)) if self._clauses else "" + + def with_params(self, *extra: Any) -> list[Any]: + """The bound values followed by more (LIMIT, OFFSET), without + changing this builder.""" + return [*self.params, *extra] diff --git a/app/wifi/collectors/base.py b/app/wifi/collectors/base.py index 8460669..484c1c9 100644 --- a/app/wifi/collectors/base.py +++ b/app/wifi/collectors/base.py @@ -93,4 +93,5 @@ async def test_connection(self) -> tuple[bool, str]: result = await self.poll() return True, f"OK — {len(result.access_points)} access point(s) found" except Exception as exc: - return False, str(exc) + from app.errors import describe_exception + return False, describe_exception(exc) diff --git a/app/wifi/collectors/unifi.py b/app/wifi/collectors/unifi.py index 6fe0572..deda2d8 100644 --- a/app/wifi/collectors/unifi.py +++ b/app/wifi/collectors/unifi.py @@ -130,7 +130,8 @@ async def _resolve_https_base(client: httpx.AsyncClient, base: str, probe_path: probe = await client.get(f"{base}{probe_path}", follow_redirects=True) port = f":{probe.url.port}" if probe.url.port else "" return f"{probe.url.scheme}://{probe.url.host}{port}" - except Exception: + except Exception as exc: + log.debug(f"HTTPS probe of {base} failed ({type(exc).__name__}); using the configured URL") return base # best-effort — fall back to the configured URL if the probe itself fails @@ -233,13 +234,15 @@ async def _poll_api_key(self) -> PollResult: dr = await client.get(f"{integration_base}/sites/{site_id}/devices/{dev_id}") dr.raise_for_status() radio_detail[dev_id] = (dr.json().get("interfaces") or {}).get("radios") or [] - except Exception: + except Exception as exc: + log.warning(f"UniFi device {dev_id}: radio detail unavailable ({type(exc).__name__}) — reporting it without radios") radio_detail[dev_id] = [] try: sr = await client.get(f"{integration_base}/sites/{site_id}/devices/{dev_id}/statistics/latest") sr.raise_for_status() device_stats[dev_id] = sr.json() or {} - except Exception: + except Exception as exc: + log.warning(f"UniFi device {dev_id}: statistics unavailable ({type(exc).__name__})") device_stats[dev_id] = {} result = PollResult() diff --git a/app/wifi/poll_engine.py b/app/wifi/poll_engine.py index 4d05a51..b4980f1 100644 --- a/app/wifi/poll_engine.py +++ b/app/wifi/poll_engine.py @@ -13,6 +13,7 @@ import aiosqlite +from app.errors import describe_exception from app.wifi.collectors.base import PollResult from app.wifi.collectors.crypto import decrypt_config, decrypt_str from app.wifi.collectors.registry import get_collector_instance @@ -346,7 +347,7 @@ async def poll_and_store(db: aiosqlite.Connection, row: aiosqlite.Row) -> PollRe await db.commit() return result except Exception as exc: - detail = str(exc) or type(exc).__name__ + detail = describe_exception(exc) if isinstance(exc, asyncio.TimeoutError): detail = f"Controller did not answer within {_POLL_TIMEOUT_SECONDS} seconds" log.warning(f"Collector '{row['name']}' poll failed: {detail}") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c5af670..8beb0c2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -255,6 +255,9 @@ export const api = { getSettings: () => request>('/settings'), updateSettings: (values: Record) => request('/settings', { method: 'PUT', body: JSON.stringify({ values }) }), + parseSamlMetadata: (xml: string) => + request<{ entity_id: string; sso_url: string; cert: string }>( + '/settings/saml/parse-metadata', { method: 'POST', body: JSON.stringify({ xml }) }), testNotification: (channel: string) => request<{ status: string; detail?: string }>('/settings/test-notification', { method: 'POST', diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index b616e78..2d02c67 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,2344 +1,23 @@ -import { Fragment, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { - api, User, UserIn, Integration, IntegrationInput, SslStatus, UserApiKey, - Collector, CollectorType, FieldSchema, Site, WifiCredential, WifiCredentialInput, CredType, - CredentialTestInput, + Collector, + Integration, + User, + api, } from '../api/client' -import { useAuth } from '../store/auth' -import HelpButton from '../components/HelpButton' -import CollectorConfigForm from '../components/CollectorConfigForm' -import { copyToClipboard } from '../utils/clipboard' -import { BrandLockup } from '../components/Brand' - -// -- Generic helpers ------------------------------------------------------------- -type SettingsMap = Record - -function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
-
-

{label}

- {hint &&

{hint}

} -
-
{children}
-
- ) -} - -function TextInput({ value, onChange, placeholder = '', secret = false, mono = false }: { - value: string; onChange: (v: string) => void - placeholder?: string; secret?: boolean; mono?: boolean -}) { - return ( - onChange(e.target.value)} - placeholder={placeholder} - className={`w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-sky-500 ${mono ? 'font-mono' : ''}`} - /> - ) -} - -function NumberInput({ value, onChange, min, max }: { - value: number; onChange: (v: number) => void; min?: number; max?: number -}) { - return ( - onChange(parseInt(e.target.value) || 0)} - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-sky-500" - /> - ) -} - -function Toggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) { - return ( - - ) -} - -function SelectInput({ value, onChange, options }: { - value: string; onChange: (v: string) => void; options: Array<{ value: string; label: string }> -}) { - return ( - - ) -} - -function SendTestButton({ channel }: { channel: string }) { - const [status, setStatus] = useState<'idle' | 'loading' | 'sent' | 'failed' | 'skipped'>('idle') - const [detail, setDetail] = useState('') - - const run = async () => { - setStatus('loading') - setDetail('') - try { - const res = await api.testNotification(channel) - setStatus(res.status as 'sent' | 'failed' | 'skipped') - setDetail(res.detail || '') - } catch (e) { - setStatus('failed') - setDetail(String(e)) - } - } - - return ( -
- - {status === 'sent' && ✓ Sent{detail ? ` — ${detail}` : ''}} - {status === 'skipped' && ⚠ Skipped — {detail}} - {status === 'failed' && ✗ Failed — {detail}} -
- ) -} - -// ── Snapshot files vary per backup, so the checkbox set is derived from -// what's actually in that snapshot ── -function SnapshotRestoreRow({ snapshot, onRestored }: { - snapshot: { name: string; path: string; size_bytes: number; files: string[] } - onRestored: (name: string, result: Record) => void -}) { - const [expanded, setExpanded] = useState(false) - const [selected, setSelected] = useState>(new Set(snapshot.files)) - const [running, setRunning] = useState(false) - const [error, setError] = useState(null) - - const toggle = (f: string) => { - setSelected(prev => { - const next = new Set(prev) - if (next.has(f)) next.delete(f); else next.add(f) - return next - }) - } - - const restore = async () => { - if (selected.size === 0) return - const which = selected.size === snapshot.files.length ? 'all files' : Array.from(selected).join(', ') - if (!window.confirm(`Restore ${which} from ${snapshot.name}?\n\nThis overwrites current data and cannot be undone.`)) return - setRunning(true) - setError(null) - try { - const result = await api.restoreSnapshot(snapshot.name, Array.from(selected)) - onRestored(snapshot.name, result) - setExpanded(false) - } catch (e: any) { - setError(e.message || 'Restore failed') - } finally { - setRunning(false) - } - } - - return ( -
-
- {snapshot.name} - {(snapshot.size_bytes / 1024 / 1024).toFixed(1)} MB - {snapshot.files.join(', ')} - -
- {expanded && ( -
-

Choose which files to restore:

-
- {snapshot.files.map(f => ( - - ))} -
- - {error &&

{error}

} -
- )} -
- ) -} - -function RestartServiceRow() { - const [state, setState] = useState<'idle' | 'restarting' | 'done' | 'error'>('idle') - - const restart = async () => { - if (state === 'restarting') return - setState('restarting') - try { - await api.restartService() - setState('done') - setTimeout(() => setState('idle'), 8000) - } catch { - setState('error') - setTimeout(() => setState('idle'), 4000) - } - } - - return ( -
-
-

Restart Service

-

Apply backend changes or recover from errors

-
-
- - {state === 'done' && Service is restarting — reload the page in ~5 seconds} - {state === 'error' && Restart failed — check server logs} -
-
- ) -} - -// -- Port field — lives in config.yaml, not the SQLite-backed settings; value -// is lifted to the parent so it saves through the General tab's one Save button -- -function PortField({ value, onChange, loaded }: { value: number; onChange: (v: number) => void; loaded: boolean }) { - return ( - - {!loaded ? ( -

Loading…

- ) : ( - - )} -
- ) -} - -// -- Section wrapper with Save ---------------------------------------------------- -// ── Log forwarding tester ───────────────────────────────────────────────────── -// A forwarder that silently drops everything looks identical to one that works, -// so the settings page has to be able to prove the path end to end. -function LogForwardTester({ host, port, protocol }: { host: string; port: number; protocol: string }) { - const [busy, setBusy] = useState(false) - const [result, setResult] = useState('') - const [ok, setOk] = useState(null) - - const run = async () => { - setBusy(true); setResult(''); setOk(null) - try { - const r = await api.logForwardTest(host, port, protocol) - setOk(r.ok) - setResult(r.ok - ? `Sent 1 message to ${r.target} — check pktLog for "pktWiFi log forwarding test message"` - : `Failed: ${r.last_error || 'no bytes sent'}`) - } catch (e: any) { - setOk(false); setResult(e.message || 'Test failed') - } finally { - setBusy(false) - } - } - - return ( - -
- - {result && ( - {result} - )} - {!host && Set a collector host first} -
-
- ) -} - -function Section({ title, help, children, onSave, saving, saved, error }: { - title: string - help?: { title: string; content: React.ReactNode } - children: React.ReactNode - onSave: () => Promise - saving: boolean - saved: boolean - error: string -}) { - return ( -
-
-

{title}

- {help && {help.content}} -
-
{children}
-
- - {saved && Saved} - {error && {error}} -
-
- ) -} - -// ── Resonance origin ────────────────────────────────────────────────────────── -// The one string that has to be copied onto the resonance key, so it is edited -// and copied in the same place. Showing it twice — once editable in the form and -// once read-only beside a Copy button — reliably sends people to the copy. -function ResonanceOriginField({ value, onChange }: { value: string; onChange: (v: string) => void }) { - const [detected, setDetected] = useState('') - const [copied, setCopied] = useState(false) - - useEffect(() => { api.resonanceStatus().then(r => setDetected(r.detected_origin || '')).catch(() => {}) }, []) - - const effective = value.trim() || detected - - return ( -
-
- - -
- {!value.trim() && detected && ( -

- Blank — using {detected}. -

- )} -
- ) -} - -// ── Resonance diagnostics ───────────────────────────────────────────────────── -// Everything here answers a question an admin would otherwise have to open the -// resonance console to answer: what does this key actually allow, is this -// install's origin the one the key expects, and is the widget reaching anyone. -function ResonanceDiagnostics({ baseUrl, keyValue }: { baseUrl: string; keyValue: string }) { - const [testing, setTesting] = useState(false) - const [result, setResult] = useState> | null>(null) - const [status, setStatus] = useState> | null>(null) - - const loadStatus = () => { api.resonanceStatus().then(setStatus).catch(() => {}) } - useEffect(loadStatus, []) - - const runTest = async () => { - setTesting(true) - setResult(null) - try { - setResult(await api.resonanceTest(baseUrl, keyValue)) - } catch (e: any) { - setResult({ ok: false, error: e.message || 'Test failed', origin: '' }) - } finally { - setTesting(false) - loadStatus() - } - } - - const cap = (result?.cap || {}) as Record - const failures = status?.load_failures - - return ( -
-
-

Diagnostics

-
-
- - {/* getUserMedia is gated on a secure context, so the microphone cannot - work over plain HTTP however the key is configured. */} - {!window.isSecureContext && ( -

- Served over HTTP — voice is unavailable. Text chat is unaffected. -

- )} - -
- - Works whether or not the widget is enabled. -
- - {result && !result.ok && ( -
-

{result.error}

- {result.detail &&

{result.detail}

} -
- )} - - {result?.ok && ( -
-

Connected — this key grants:

-

- ask {cap.ask ? '✓' : '✗'} · mic {cap.mic ? '✓' : '✗'} · speak {cap.speak ? '✓' : '✗'} -

-

- Limits: {String(cap.rate_per_min ?? '?')}/min per key, {String(cap.rate_per_visitor ?? '?')}/min per person -

-

- Session: {result.expires_in ? Math.round(result.expires_in / 60) : '?'} min · Code: {result.code_expires_in ?? '?'}s -

-

Sent as {result.user_id_sent}

-
- )} - - {status?.breaker.open && ( -

- Paused after {status.breaker.failures} failures — retrying in {status.breaker.retry_in_seconds}s. - {status.breaker.last_error ? ` Last error: ${status.breaker.last_error}` : ''} -

- )} - - {failures && failures.events > 0 && ( -

- The widget failed to load for {failures.users} user{failures.users === 1 ? '' : 's'} - {' '}({failures.events} time{failures.events === 1 ? '' : 's'}) in the last {failures.days} days. - Common causes are an ad blocker, a wrong server address, or resonance being unreachable. -

- )} -
-
- ) -} - -interface SaveState { saving: boolean; saved: boolean; error: string } -const INIT: SaveState = { saving: false, saved: false, error: '' } - -function useSave(keys: string[], settings: SettingsMap, onSuccess: () => void) { - const [state, setState] = useState(INIT) - - const save = async () => { - setState({ saving: true, saved: false, error: '' }) - try { - const subset: SettingsMap = {} - for (const k of keys) if (k in settings) subset[k] = settings[k] - await api.updateSettings(subset) - setState({ saving: false, saved: true, error: '' }) - onSuccess() - setTimeout(() => setState(s => ({ ...s, saved: false })), 3000) - } catch (e: any) { - setState({ saving: false, saved: false, error: e.message || 'Save failed' }) - } - } - - return { ...state, save } -} - -// -- Drag-and-drop cert/key textarea ---------------------------------------------- -function CertTextarea({ value, onChange, rows = 4, placeholder = 'MIIDp…', secret = false }: { - value: string; onChange: (v: string) => void; rows?: number; placeholder?: string; secret?: boolean -}) { - const [dragging, setDragging] = useState(false) - const [revealed, setRevealed] = useState(false) - - const stripPem = (raw: string) => - raw.replace(/-----BEGIN[^-]+-----/g, '').replace(/-----END[^-]+-----/g, '').replace(/\s+/g, '') - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - setDragging(false) - const file = e.dataTransfer.files[0] - if (!file) return - const reader = new FileReader() - reader.onload = () => { onChange(stripPem(reader.result as string)); setRevealed(false) } - reader.readAsText(file) - } - - if (secret && value && !revealed) { - return ( -
-
- ✓ Certificate saved -
- - -
- ) - } - - return ( -
{ e.preventDefault(); setDragging(true) }} - onDragLeave={() => setDragging(false)} - onDrop={handleDrop} - className={`relative rounded-lg transition-colors ${dragging ? 'ring-2 ring-sky-400 bg-sky-950/30' : ''}`} - > - {secret && revealed && ( -
- -
- )} -