From 9aab271753086b45858b1a88e3b35f7e8eb842dc Mon Sep 17 00:00:00 2001 From: Robert Barnett Date: Mon, 5 Oct 2026 18:20:04 -0400 Subject: [PATCH 1/2] Split the largest modules, stop returning raw error text, share the filter builder, and fix the Resonance band filter Settings.tsx is split into a settings/ folder of per-tab files, and widgets.py and resonance_data.py become packages. Code is moved unchanged: every route and every response matches the previous code. Errors: the suite and system endpoints no longer return raw database or file-path messages, the widget pages show only the kind of failure, and error text that is stored or returned has URL credentials and query strings removed. Silent handlers in the UniFi collector, collector config and widget queries now log. A shared Where builder replaces the hand-built filters at six list endpoints and refuses a fragment whose placeholders do not match its values. The Resonance band filter takes 2.4, 5 or 6 but compared that against stored values such as 5GHz, so it never matched anything. It now matches. Adds tests for the filters, redaction and the error handling. --- README.md | 4 +- app/api/collectors.py | 4 + app/api/credentials.py | 3 +- app/api/logs.py | 25 +- app/api/nav.py | 2 +- app/api/resonance_data.py | 1384 --------- app/api/resonance_data/__init__.py | 44 + app/api/resonance_data/_common.py | 80 + app/api/resonance_data/documents.py | 221 ++ app/api/resonance_data/errors.py | 72 + app/api/resonance_data/models.py | 226 ++ app/api/resonance_data/read.py | 566 ++++ app/api/resonance_data/session.py | 101 + app/api/resonance_data/writes.py | 146 + app/api/suite.py | 22 +- app/api/system.py | 14 +- app/api/widgets.py | 1053 ------- app/api/widgets/__init__.py | 13 + app/api/widgets/_common.py | 278 ++ app/api/widgets/clients.py | 158 + app/api/widgets/fleet.py | 195 ++ app/api/widgets/manifest.py | 190 ++ app/api/widgets/options.py | 34 + app/api/widgets/rf.py | 253 ++ app/backup.py | 1 + app/dependencies.py | 2 +- app/errors.py | 28 + app/integrations/suite_client.py | 8 +- app/sqlutil.py | 35 + app/wifi/collectors/base.py | 3 +- app/wifi/collectors/unifi.py | 9 +- app/wifi/poll_engine.py | 3 +- frontend/src/pages/Settings.tsx | 2541 +---------------- frontend/src/pages/settings/ApiKeysTab.tsx | 262 ++ .../src/pages/settings/ControllersTab.tsx | 399 +++ .../src/pages/settings/CredentialsTab.tsx | 306 ++ frontend/src/pages/settings/Integrations.tsx | 210 ++ frontend/src/pages/settings/SitesTab.tsx | 145 + frontend/src/pages/settings/SslPanel.tsx | 187 ++ frontend/src/pages/settings/UsersTab.tsx | 444 +++ frontend/src/pages/settings/panels.tsx | 504 ++++ frontend/src/pages/settings/shared.tsx | 130 + tests/test_query_helpers.py | 226 ++ 43 files changed, 5536 insertions(+), 4995 deletions(-) delete mode 100644 app/api/resonance_data.py create mode 100644 app/api/resonance_data/__init__.py create mode 100644 app/api/resonance_data/_common.py create mode 100644 app/api/resonance_data/documents.py create mode 100644 app/api/resonance_data/errors.py create mode 100644 app/api/resonance_data/models.py create mode 100644 app/api/resonance_data/read.py create mode 100644 app/api/resonance_data/session.py create mode 100644 app/api/resonance_data/writes.py delete mode 100644 app/api/widgets.py create mode 100644 app/api/widgets/__init__.py create mode 100644 app/api/widgets/_common.py create mode 100644 app/api/widgets/clients.py create mode 100644 app/api/widgets/fleet.py create mode 100644 app/api/widgets/manifest.py create mode 100644 app/api/widgets/options.py create mode 100644 app/api/widgets/rf.py create mode 100644 app/errors.py create mode 100644 app/sqlutil.py create mode 100644 frontend/src/pages/settings/ApiKeysTab.tsx create mode 100644 frontend/src/pages/settings/ControllersTab.tsx create mode 100644 frontend/src/pages/settings/CredentialsTab.tsx create mode 100644 frontend/src/pages/settings/Integrations.tsx create mode 100644 frontend/src/pages/settings/SitesTab.tsx create mode 100644 frontend/src/pages/settings/SslPanel.tsx create mode 100644 frontend/src/pages/settings/UsersTab.tsx create mode 100644 frontend/src/pages/settings/panels.tsx create mode 100644 frontend/src/pages/settings/shared.tsx create mode 100644 tests/test_query_helpers.py 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/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..8b4bb8b --- /dev/null +++ b/app/api/widgets/rf.py @@ -0,0 +1,253 @@ +""" +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) ──────────────────────────────────────────────── +_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)) 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/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 && ( -
- -
- )} -