diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py index ad8508d..e4531cd 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -87,6 +87,8 @@ async def list_devices( group_id: Optional[uuid.UUID] = Query(None), sort_by: str = Query("created_at", description="Field to sort by"), sort_order: str = Query("desc", description="asc or desc"), + site_id: Optional[uuid.UUID] = Query(None, description="Filter by site"), + sector_id: Optional[uuid.UUID] = Query(None, description="Filter by sector"), current_user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> DeviceListResponse: @@ -104,6 +106,8 @@ async def list_devices( group_id=group_id, sort_by=sort_by, sort_order=sort_order, + site_id=site_id, + sector_id=sector_id, ) return DeviceListResponse(items=items, total=total, page=page, page_size=page_size) diff --git a/backend/app/routers/links.py b/backend/app/routers/links.py index ff8a9e7..7e9b201 100644 --- a/backend/app/routers/links.py +++ b/backend/app/routers/links.py @@ -18,7 +18,12 @@ 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.schemas.link import ( + LinkListResponse, + RegistrationListResponse, + RFStatsListResponse, + UnknownClientListResponse, +) from app.services import link_service router = APIRouter(tags=["links"]) @@ -73,6 +78,38 @@ async def list_site_links( 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}/registrations", + response_model=RegistrationListResponse, + summary="List device wireless registrations", +) +async def list_device_registrations( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> RegistrationListResponse: + """Get latest wireless registration data for a device (most recent per MAC).""" + await _check_tenant_access(current_user, tenant_id, db) + return await link_service.get_device_registrations(db=db, tenant_id=tenant_id, device_id=device_id) + + +@router.get( + "/tenants/{tenant_id}/devices/{device_id}/rf-stats", + response_model=RFStatsListResponse, + summary="List device RF monitor stats", +) +async def list_device_rf_stats( + tenant_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> RFStatsListResponse: + """Get latest RF monitor stats for a device (most recent per interface).""" + await _check_tenant_access(current_user, tenant_id, db) + return await link_service.get_device_rf_stats(db=db, tenant_id=tenant_id, device_id=device_id) + + @router.get( "/tenants/{tenant_id}/devices/{device_id}/unknown-clients", response_model=UnknownClientListResponse, diff --git a/backend/app/schemas/link.py b/backend/app/schemas/link.py index 095e00a..6e8b067 100644 --- a/backend/app/schemas/link.py +++ b/backend/app/schemas/link.py @@ -54,3 +54,48 @@ class UnknownClientListResponse(BaseModel): items: list[UnknownClientResponse] total: int + + +class RegistrationResponse(BaseModel): + """A single wireless registration entry for a device.""" + + mac_address: str + interface: str | None = None + signal_strength: int | None = None + tx_ccq: int | None = None + tx_rate: str | None = None + rx_rate: str | None = None + distance: int | None = None + uptime: str | None = None + last_seen: datetime + hostname: str | None = None + device_id: str | None = None + + model_config = ConfigDict(from_attributes=True) + + +class RegistrationListResponse(BaseModel): + """List of wireless registrations with total count.""" + + items: list[RegistrationResponse] + total: int + + +class RFStatsResponse(BaseModel): + """RF monitor stats for a single interface.""" + + interface: str + noise_floor: int | None = None + channel_width: int | None = None + tx_power: int | None = None + registered_clients: int | None = None + last_seen: datetime + + model_config = ConfigDict(from_attributes=True) + + +class RFStatsListResponse(BaseModel): + """List of RF stats with total count.""" + + items: list[RFStatsResponse] + total: int diff --git a/backend/app/services/device.py b/backend/app/services/device.py index ce7c715..6eb9675 100644 --- a/backend/app/services/device.py +++ b/backend/app/services/device.py @@ -197,6 +197,8 @@ async def get_devices( group_id: Optional[uuid.UUID] = None, sort_by: str = "created_at", sort_order: str = "desc", + site_id: Optional[uuid.UUID] = None, + sector_id: Optional[uuid.UUID] = None, ) -> tuple[list[DeviceResponse], int]: """ Return a paginated list of devices with optional filtering and sorting. @@ -235,6 +237,12 @@ async def get_devices( ) ) + if site_id: + base_q = base_q.where(Device.site_id == site_id) + + if sector_id: + base_q = base_q.where(Device.sector_id == sector_id) + # Count total before pagination count_q = select(func.count()).select_from(base_q.subquery()) total_result = await db.execute(count_q) diff --git a/backend/app/services/link_service.py b/backend/app/services/link_service.py index d3bf6e3..e60b855 100644 --- a/backend/app/services/link_service.py +++ b/backend/app/services/link_service.py @@ -14,6 +14,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.schemas.link import ( LinkListResponse, LinkResponse, + RegistrationListResponse, + RegistrationResponse, + RFStatsListResponse, + RFStatsResponse, UnknownClientListResponse, UnknownClientResponse, ) @@ -180,3 +184,92 @@ async def get_unknown_clients( ] return UnknownClientListResponse(items=items, total=len(items)) + + +async def get_device_registrations( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, +) -> RegistrationListResponse: + """Get latest wireless registration data for a device (most recent per MAC address). + + Uses DISTINCT ON to return the most recent registration per unique MAC address. + Attempts to resolve MAC addresses to known device hostnames via device_interfaces. + """ + result = await db.execute( + text(""" + SELECT DISTINCT ON (wr.mac_address) + wr.mac_address, wr.interface, wr.signal_strength, + wr.tx_ccq, wr.tx_rate, wr.rx_rate, + wr.distance, wr.uptime, wr.time AS last_seen, + di.device_id AS resolved_device_id, + d.hostname AS resolved_hostname + FROM wireless_registrations wr + LEFT JOIN device_interfaces di + ON di.mac_address = wr.mac_address AND di.tenant_id = wr.tenant_id + LEFT JOIN devices d + ON d.id = di.device_id + WHERE wr.device_id = :device_id + AND wr.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 = [ + RegistrationResponse( + mac_address=row.mac_address, + interface=row.interface, + signal_strength=row.signal_strength, + tx_ccq=row.tx_ccq, + tx_rate=row.tx_rate, + rx_rate=row.rx_rate, + distance=row.distance, + uptime=row.uptime, + last_seen=row.last_seen, + hostname=row.resolved_hostname, + device_id=str(row.resolved_device_id) if row.resolved_device_id else None, + ) + for row in rows + ] + + return RegistrationListResponse(items=items, total=len(items)) + + +async def get_device_rf_stats( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, +) -> RFStatsListResponse: + """Get latest RF monitor stats for a device (most recent per interface). + + Uses DISTINCT ON to return the most recent stats per unique interface name. + """ + result = await db.execute( + text(""" + SELECT DISTINCT ON (rf.interface) + rf.interface, rf.noise_floor, rf.channel_width, + rf.tx_power, rf.registered_clients, rf.time AS last_seen + FROM rf_monitor_stats rf + WHERE rf.device_id = :device_id + AND rf.tenant_id = :tenant_id + ORDER BY rf.interface, rf.time DESC + """), + {"device_id": str(device_id), "tenant_id": str(tenant_id)}, + ) + rows = result.fetchall() + + items = [ + RFStatsResponse( + interface=row.interface, + noise_floor=row.noise_floor, + channel_width=row.channel_width, + tx_power=row.tx_power, + registered_clients=row.registered_clients, + last_seen=row.last_seen, + ) + for row in rows + ] + + return RFStatsListResponse(items=items, total=len(items)) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 484078c..853199c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -566,6 +566,178 @@ export const sitesApi = { .then((r) => r.data), } +// ─── Sectors ────────────────────────────────────────────────────────────────── + +export interface SectorResponse { + id: string + site_id: string + name: string + azimuth: number | null + description: string | null + device_count: number + created_at: string + updated_at: string +} + +export interface SectorListResponse { + items: SectorResponse[] + total: number +} + +export interface SectorCreate { + name: string + azimuth?: number | null + description?: string | null +} + +export interface SectorUpdate { + name?: string + azimuth?: number | null + description?: string | null +} + +export const sectorsApi = { + list: (tenantId: string, siteId: string) => + api + .get(`/api/tenants/${tenantId}/sites/${siteId}/sectors`) + .then((r) => r.data), + + create: (tenantId: string, siteId: string, data: SectorCreate) => + api + .post(`/api/tenants/${tenantId}/sites/${siteId}/sectors`, data) + .then((r) => r.data), + + update: (tenantId: string, siteId: string, sectorId: string, data: SectorUpdate) => + api + .put( + `/api/tenants/${tenantId}/sites/${siteId}/sectors/${sectorId}`, + data, + ) + .then((r) => r.data), + + delete: (tenantId: string, siteId: string, sectorId: string) => + api + .delete(`/api/tenants/${tenantId}/sites/${siteId}/sectors/${sectorId}`) + .then((r) => r.data), + + assignDevice: (tenantId: string, deviceId: string, sectorId: string | null) => + api + .put(`/api/tenants/${tenantId}/devices/${deviceId}/sector`, { + sector_id: sectorId, + }) + .then((r) => r.data), +} + +// ─── Wireless ───────────────────────────────────────────────────────────────── + +export interface RegistrationResponse { + mac_address: string + interface: string | null + signal_strength: number | null + tx_ccq: number | null + tx_rate: string | null + rx_rate: string | null + distance: number | null + uptime: string | null + last_seen: string + hostname: string | null + device_id: string | null +} + +export interface RegistrationListResponse { + items: RegistrationResponse[] + total: number +} + +export interface RFStatsResponse { + interface: string + noise_floor: number | null + channel_width: number | null + tx_power: number | null + registered_clients: number | null + last_seen: string +} + +export interface RFStatsListResponse { + items: RFStatsResponse[] + total: number +} + +export interface LinkResponse { + id: string + ap_device_id: string + cpe_device_id: string + ap_hostname: string | null + cpe_hostname: string | null + interface: string | null + client_mac: string + signal_strength: number | null + tx_ccq: number | null + tx_rate: string | null + rx_rate: string | null + state: string + missed_polls: number + discovered_at: string + last_seen: string +} + +export interface LinkListResponse { + items: LinkResponse[] + total: number +} + +export interface UnknownClientResponse { + mac_address: string + interface: string | null + signal_strength: number | null + tx_rate: string | null + rx_rate: string | null + last_seen: string +} + +export interface UnknownClientListResponse { + items: UnknownClientResponse[] + total: number +} + +export const wirelessApi = { + getLinks: (tenantId: string, params?: { state?: string; device_id?: string }) => + api + .get(`/api/tenants/${tenantId}/links`, { params }) + .then((r) => r.data), + + getSiteLinks: (tenantId: string, siteId: string) => + api + .get(`/api/tenants/${tenantId}/sites/${siteId}/links`) + .then((r) => r.data), + + getDeviceLinks: (tenantId: string, deviceId: string) => + api + .get(`/api/tenants/${tenantId}/devices/${deviceId}/links`) + .then((r) => r.data), + + getDeviceRegistrations: (tenantId: string, deviceId: string) => + api + .get( + `/api/tenants/${tenantId}/devices/${deviceId}/registrations`, + ) + .then((r) => r.data), + + getDeviceRFStats: (tenantId: string, deviceId: string) => + api + .get( + `/api/tenants/${tenantId}/devices/${deviceId}/rf-stats`, + ) + .then((r) => r.data), + + getUnknownClients: (tenantId: string, deviceId: string) => + api + .get( + `/api/tenants/${tenantId}/devices/${deviceId}/unknown-clients`, + ) + .then((r) => r.data), +} + // ─── Metrics ────────────────────────────────────────────────────────────────── export interface HealthMetricPoint {