feat(13-03): add link service, schemas, router, and wire subscribers into lifespan
- LinkResponse/UnknownClientResponse Pydantic schemas with from_attributes - Link service with get_links, get_device_links, get_site_links, get_unknown_clients - Unknown clients query uses DISTINCT ON for latest registration per MAC - 4 REST endpoints: tenant links, device links, site links, unknown clients - Interface and link discovery subscribers wired into FastAPI lifespan start/stop - Links router registered at /api prefix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -245,6 +245,30 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Wireless registration subscriber failed to start (non-fatal): %s", e)
|
logger.error("Wireless registration subscriber failed to start (non-fatal): %s", e)
|
||||||
|
|
||||||
|
# Start NATS subscriber for device interface data (MAC resolution for link discovery).
|
||||||
|
interface_nc = None
|
||||||
|
try:
|
||||||
|
from app.services.interface_subscriber import (
|
||||||
|
start_interface_subscriber,
|
||||||
|
stop_interface_subscriber,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface_nc = await start_interface_subscriber()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Interface subscriber failed to start (non-fatal): %s", e)
|
||||||
|
|
||||||
|
# Start NATS subscriber for wireless link discovery (MAC resolution + state machine).
|
||||||
|
link_discovery_nc = None
|
||||||
|
try:
|
||||||
|
from app.services.link_discovery_subscriber import (
|
||||||
|
start_link_discovery_subscriber,
|
||||||
|
stop_link_discovery_subscriber,
|
||||||
|
)
|
||||||
|
|
||||||
|
link_discovery_nc = await start_link_discovery_subscriber()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Link discovery subscriber failed to start (non-fatal): %s", e)
|
||||||
|
|
||||||
# Start retention cleanup scheduler (daily purge of expired config snapshots)
|
# Start retention cleanup scheduler (daily purge of expired config snapshots)
|
||||||
try:
|
try:
|
||||||
await start_retention_scheduler()
|
await start_retention_scheduler()
|
||||||
@@ -340,6 +364,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
await stop_config_snapshot_subscriber()
|
await stop_config_snapshot_subscriber()
|
||||||
if wireless_reg_nc:
|
if wireless_reg_nc:
|
||||||
await stop_wireless_registration_subscriber(wireless_reg_nc)
|
await stop_wireless_registration_subscriber(wireless_reg_nc)
|
||||||
|
if interface_nc:
|
||||||
|
await stop_interface_subscriber(interface_nc)
|
||||||
|
if link_discovery_nc:
|
||||||
|
await stop_link_discovery_subscriber(link_discovery_nc)
|
||||||
await stop_retention_scheduler()
|
await stop_retention_scheduler()
|
||||||
|
|
||||||
# Dispose database engine connections to release all pooled connections cleanly.
|
# Dispose database engine connections to release all pooled connections cleanly.
|
||||||
@@ -405,6 +433,7 @@ def create_app() -> FastAPI:
|
|||||||
from app.routers.remote_access import router as remote_access_router
|
from app.routers.remote_access import router as remote_access_router
|
||||||
from app.routers.winbox_remote import router as winbox_remote_router
|
from app.routers.winbox_remote import router as winbox_remote_router
|
||||||
from app.routers.sites import router as sites_router
|
from app.routers.sites import router as sites_router
|
||||||
|
from app.routers.links import router as links_router
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api")
|
app.include_router(auth_router, prefix="/api")
|
||||||
app.include_router(tenants_router, prefix="/api")
|
app.include_router(tenants_router, prefix="/api")
|
||||||
@@ -435,6 +464,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(remote_access_router, prefix="/api")
|
app.include_router(remote_access_router, prefix="/api")
|
||||||
app.include_router(winbox_remote_router, prefix="/api")
|
app.include_router(winbox_remote_router, prefix="/api")
|
||||||
app.include_router(sites_router, prefix="/api")
|
app.include_router(sites_router, prefix="/api")
|
||||||
|
app.include_router(links_router, prefix="/api")
|
||||||
|
|
||||||
# Health check endpoints
|
# Health check endpoints
|
||||||
@app.get("/health", tags=["health"])
|
@app.get("/health", tags=["health"])
|
||||||
|
|||||||
89
backend/app/routers/links.py
Normal file
89
backend/app/routers/links.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
Wireless link API endpoints.
|
||||||
|
|
||||||
|
Routes: /api/tenants/{tenant_id}/links, /api/tenants/{tenant_id}/devices/{device_id}/links,
|
||||||
|
/api/tenants/{tenant_id}/sites/{site_id}/links,
|
||||||
|
/api/tenants/{tenant_id}/devices/{device_id}/unknown-clients
|
||||||
|
|
||||||
|
RBAC:
|
||||||
|
- viewer: GET (read-only) -- all endpoints are GET-only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.middleware.tenant_context import CurrentUser, get_current_user
|
||||||
|
from app.routers.devices import _check_tenant_access
|
||||||
|
from app.schemas.link import LinkListResponse, UnknownClientListResponse
|
||||||
|
from app.services import link_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["links"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tenants/{tenant_id}/links",
|
||||||
|
response_model=LinkListResponse,
|
||||||
|
summary="List wireless links",
|
||||||
|
)
|
||||||
|
async def list_links(
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
state: Optional[str] = Query(None, description="Filter by link state (active, degraded, down, stale)"),
|
||||||
|
device_id: Optional[uuid.UUID] = Query(None, description="Filter by device (AP or CPE side)"),
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> LinkListResponse:
|
||||||
|
"""List all wireless links for a tenant with optional state and device filters."""
|
||||||
|
await _check_tenant_access(current_user, tenant_id, db)
|
||||||
|
return await link_service.get_links(db=db, tenant_id=tenant_id, state=state, device_id=device_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tenants/{tenant_id}/devices/{device_id}/links",
|
||||||
|
response_model=LinkListResponse,
|
||||||
|
summary="List device links",
|
||||||
|
)
|
||||||
|
async def list_device_links(
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
device_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> LinkListResponse:
|
||||||
|
"""List wireless links where the given device is either the AP or CPE."""
|
||||||
|
await _check_tenant_access(current_user, tenant_id, db)
|
||||||
|
return await link_service.get_device_links(db=db, tenant_id=tenant_id, device_id=device_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tenants/{tenant_id}/sites/{site_id}/links",
|
||||||
|
response_model=LinkListResponse,
|
||||||
|
summary="List site links",
|
||||||
|
)
|
||||||
|
async def list_site_links(
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
site_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> LinkListResponse:
|
||||||
|
"""List wireless links where either the AP or CPE device belongs to the given site."""
|
||||||
|
await _check_tenant_access(current_user, tenant_id, db)
|
||||||
|
return await link_service.get_site_links(db=db, tenant_id=tenant_id, site_id=site_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tenants/{tenant_id}/devices/{device_id}/unknown-clients",
|
||||||
|
response_model=UnknownClientListResponse,
|
||||||
|
summary="List unknown wireless clients",
|
||||||
|
)
|
||||||
|
async def list_unknown_clients(
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
device_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> UnknownClientListResponse:
|
||||||
|
"""List wireless clients whose MAC does not resolve to any known device interface."""
|
||||||
|
await _check_tenant_access(current_user, tenant_id, db)
|
||||||
|
return await link_service.get_unknown_clients(db=db, tenant_id=tenant_id, device_id=device_id)
|
||||||
56
backend/app/schemas/link.py
Normal file
56
backend/app/schemas/link.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Pydantic schemas for Link and Unknown Client endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class LinkResponse(BaseModel):
|
||||||
|
"""Single wireless link between an AP and CPE device."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
ap_device_id: uuid.UUID
|
||||||
|
cpe_device_id: uuid.UUID
|
||||||
|
ap_hostname: str | None = None
|
||||||
|
cpe_hostname: str | None = None
|
||||||
|
interface: str | None = None
|
||||||
|
client_mac: str
|
||||||
|
signal_strength: int | None = None
|
||||||
|
tx_ccq: int | None = None
|
||||||
|
tx_rate: str | None = None
|
||||||
|
rx_rate: str | None = None
|
||||||
|
state: str
|
||||||
|
missed_polls: int
|
||||||
|
discovered_at: datetime
|
||||||
|
last_seen: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkListResponse(BaseModel):
|
||||||
|
"""List of wireless links with total count."""
|
||||||
|
|
||||||
|
items: list[LinkResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownClientResponse(BaseModel):
|
||||||
|
"""A wireless client whose MAC does not resolve to any known device interface."""
|
||||||
|
|
||||||
|
mac_address: str
|
||||||
|
interface: str | None = None
|
||||||
|
signal_strength: int | None = None
|
||||||
|
tx_rate: str | None = None
|
||||||
|
rx_rate: str | None = None
|
||||||
|
last_seen: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownClientListResponse(BaseModel):
|
||||||
|
"""List of unknown clients with total count."""
|
||||||
|
|
||||||
|
items: list[UnknownClientResponse]
|
||||||
|
total: int
|
||||||
182
backend/app/services/link_service.py
Normal file
182
backend/app/services/link_service.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""Link service -- query layer for wireless links and unknown clients.
|
||||||
|
|
||||||
|
All functions use raw SQL via the app_user engine (RLS enforced).
|
||||||
|
Tenant isolation is handled automatically by PostgreSQL RLS policies
|
||||||
|
once the tenant context is set by the middleware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.schemas.link import (
|
||||||
|
LinkListResponse,
|
||||||
|
LinkResponse,
|
||||||
|
UnknownClientListResponse,
|
||||||
|
UnknownClientResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_links(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
device_id: Optional[uuid.UUID] = None,
|
||||||
|
) -> LinkListResponse:
|
||||||
|
"""List wireless links for a tenant with optional state and device filters.
|
||||||
|
|
||||||
|
The device_id filter matches links where the device is either the AP or CPE side.
|
||||||
|
"""
|
||||||
|
conditions = ["wl.tenant_id = :tenant_id"]
|
||||||
|
params: dict = {"tenant_id": str(tenant_id)}
|
||||||
|
|
||||||
|
if state:
|
||||||
|
conditions.append("wl.state = :state")
|
||||||
|
params["state"] = state
|
||||||
|
|
||||||
|
if device_id:
|
||||||
|
conditions.append("(wl.ap_device_id = :device_id OR wl.cpe_device_id = :device_id)")
|
||||||
|
params["device_id"] = str(device_id)
|
||||||
|
|
||||||
|
where_clause = " AND ".join(conditions)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
text(f"""
|
||||||
|
SELECT wl.id, wl.ap_device_id, wl.cpe_device_id,
|
||||||
|
ap.hostname AS ap_hostname, cpe.hostname AS cpe_hostname,
|
||||||
|
wl.interface, wl.client_mac, wl.signal_strength,
|
||||||
|
wl.tx_ccq, wl.tx_rate, wl.rx_rate, wl.state,
|
||||||
|
wl.missed_polls, wl.discovered_at, wl.last_seen
|
||||||
|
FROM wireless_links wl
|
||||||
|
LEFT JOIN devices ap ON ap.id = wl.ap_device_id
|
||||||
|
LEFT JOIN devices cpe ON cpe.id = wl.cpe_device_id
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY wl.last_seen DESC
|
||||||
|
"""),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
LinkResponse(
|
||||||
|
id=row.id,
|
||||||
|
ap_device_id=row.ap_device_id,
|
||||||
|
cpe_device_id=row.cpe_device_id,
|
||||||
|
ap_hostname=row.ap_hostname,
|
||||||
|
cpe_hostname=row.cpe_hostname,
|
||||||
|
interface=row.interface,
|
||||||
|
client_mac=row.client_mac,
|
||||||
|
signal_strength=row.signal_strength,
|
||||||
|
tx_ccq=row.tx_ccq,
|
||||||
|
tx_rate=row.tx_rate,
|
||||||
|
rx_rate=row.rx_rate,
|
||||||
|
state=row.state,
|
||||||
|
missed_polls=row.missed_polls,
|
||||||
|
discovered_at=row.discovered_at,
|
||||||
|
last_seen=row.last_seen,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return LinkListResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_device_links(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
device_id: uuid.UUID,
|
||||||
|
) -> LinkListResponse:
|
||||||
|
"""List wireless links where the given device is either the AP or CPE."""
|
||||||
|
return await get_links(db=db, tenant_id=tenant_id, device_id=device_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_site_links(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
site_id: uuid.UUID,
|
||||||
|
) -> LinkListResponse:
|
||||||
|
"""List wireless links where either the AP or CPE device belongs to the given site."""
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT wl.id, wl.ap_device_id, wl.cpe_device_id,
|
||||||
|
ap.hostname AS ap_hostname, cpe.hostname AS cpe_hostname,
|
||||||
|
wl.interface, wl.client_mac, wl.signal_strength,
|
||||||
|
wl.tx_ccq, wl.tx_rate, wl.rx_rate, wl.state,
|
||||||
|
wl.missed_polls, wl.discovered_at, wl.last_seen
|
||||||
|
FROM wireless_links wl
|
||||||
|
LEFT JOIN devices ap ON ap.id = wl.ap_device_id
|
||||||
|
LEFT JOIN devices cpe ON cpe.id = wl.cpe_device_id
|
||||||
|
WHERE wl.tenant_id = :tenant_id
|
||||||
|
AND (ap.site_id = :site_id OR cpe.site_id = :site_id)
|
||||||
|
ORDER BY wl.last_seen DESC
|
||||||
|
"""),
|
||||||
|
{"tenant_id": str(tenant_id), "site_id": str(site_id)},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
LinkResponse(
|
||||||
|
id=row.id,
|
||||||
|
ap_device_id=row.ap_device_id,
|
||||||
|
cpe_device_id=row.cpe_device_id,
|
||||||
|
ap_hostname=row.ap_hostname,
|
||||||
|
cpe_hostname=row.cpe_hostname,
|
||||||
|
interface=row.interface,
|
||||||
|
client_mac=row.client_mac,
|
||||||
|
signal_strength=row.signal_strength,
|
||||||
|
tx_ccq=row.tx_ccq,
|
||||||
|
tx_rate=row.tx_rate,
|
||||||
|
rx_rate=row.rx_rate,
|
||||||
|
state=row.state,
|
||||||
|
missed_polls=row.missed_polls,
|
||||||
|
discovered_at=row.discovered_at,
|
||||||
|
last_seen=row.last_seen,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return LinkListResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_unknown_clients(
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: uuid.UUID,
|
||||||
|
device_id: uuid.UUID,
|
||||||
|
) -> UnknownClientListResponse:
|
||||||
|
"""List wireless clients connected to a device whose MAC does not match any known device interface.
|
||||||
|
|
||||||
|
Uses DISTINCT ON to return the most recent registration per unique MAC address.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT DISTINCT ON (wr.mac_address)
|
||||||
|
wr.mac_address, wr.interface, wr.signal_strength,
|
||||||
|
wr.tx_rate, wr.rx_rate, wr.time AS last_seen
|
||||||
|
FROM wireless_registrations wr
|
||||||
|
WHERE wr.device_id = :device_id
|
||||||
|
AND wr.tenant_id = :tenant_id
|
||||||
|
AND wr.mac_address NOT IN (
|
||||||
|
SELECT di.mac_address FROM device_interfaces di
|
||||||
|
WHERE di.tenant_id = :tenant_id
|
||||||
|
)
|
||||||
|
ORDER BY wr.mac_address, wr.time DESC
|
||||||
|
"""),
|
||||||
|
{"device_id": str(device_id), "tenant_id": str(tenant_id)},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
UnknownClientResponse(
|
||||||
|
mac_address=row.mac_address,
|
||||||
|
interface=row.interface,
|
||||||
|
signal_strength=row.signal_strength,
|
||||||
|
tx_rate=row.tx_rate,
|
||||||
|
rx_rate=row.rx_rate,
|
||||||
|
last_seen=row.last_seen,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return UnknownClientListResponse(items=items, total=len(items))
|
||||||
Reference in New Issue
Block a user