feat(14-01): add site_id device filter, wireless data endpoints, and frontend API clients
- Add site_id and sector_id query parameters to devices list endpoint - Add get_device_registrations and get_device_rf_stats to link_service - Add RegistrationResponse, RFStatsResponse schemas to link.py - Add /registrations and /rf-stats endpoints to links router - Add sectorsApi frontend client (list, create, update, delete, assignDevice) - Add wirelessApi frontend client (links, registrations, RF stats, unknown clients) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,8 @@ async def list_devices(
|
|||||||
group_id: Optional[uuid.UUID] = Query(None),
|
group_id: Optional[uuid.UUID] = Query(None),
|
||||||
sort_by: str = Query("created_at", description="Field to sort by"),
|
sort_by: str = Query("created_at", description="Field to sort by"),
|
||||||
sort_order: str = Query("desc", description="asc or desc"),
|
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),
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> DeviceListResponse:
|
) -> DeviceListResponse:
|
||||||
@@ -104,6 +106,8 @@ async def list_devices(
|
|||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
sort_order=sort_order,
|
sort_order=sort_order,
|
||||||
|
site_id=site_id,
|
||||||
|
sector_id=sector_id,
|
||||||
)
|
)
|
||||||
return DeviceListResponse(items=items, total=total, page=page, page_size=page_size)
|
return DeviceListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.tenant_context import CurrentUser, get_current_user
|
from app.middleware.tenant_context import CurrentUser, get_current_user
|
||||||
from app.routers.devices import _check_tenant_access
|
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
|
from app.services import link_service
|
||||||
|
|
||||||
router = APIRouter(tags=["links"])
|
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)
|
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(
|
@router.get(
|
||||||
"/tenants/{tenant_id}/devices/{device_id}/unknown-clients",
|
"/tenants/{tenant_id}/devices/{device_id}/unknown-clients",
|
||||||
response_model=UnknownClientListResponse,
|
response_model=UnknownClientListResponse,
|
||||||
|
|||||||
@@ -54,3 +54,48 @@ class UnknownClientListResponse(BaseModel):
|
|||||||
|
|
||||||
items: list[UnknownClientResponse]
|
items: list[UnknownClientResponse]
|
||||||
total: int
|
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
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ async def get_devices(
|
|||||||
group_id: Optional[uuid.UUID] = None,
|
group_id: Optional[uuid.UUID] = None,
|
||||||
sort_by: str = "created_at",
|
sort_by: str = "created_at",
|
||||||
sort_order: str = "desc",
|
sort_order: str = "desc",
|
||||||
|
site_id: Optional[uuid.UUID] = None,
|
||||||
|
sector_id: Optional[uuid.UUID] = None,
|
||||||
) -> tuple[list[DeviceResponse], int]:
|
) -> tuple[list[DeviceResponse], int]:
|
||||||
"""
|
"""
|
||||||
Return a paginated list of devices with optional filtering and sorting.
|
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 total before pagination
|
||||||
count_q = select(func.count()).select_from(base_q.subquery())
|
count_q = select(func.count()).select_from(base_q.subquery())
|
||||||
total_result = await db.execute(count_q)
|
total_result = await db.execute(count_q)
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.schemas.link import (
|
from app.schemas.link import (
|
||||||
LinkListResponse,
|
LinkListResponse,
|
||||||
LinkResponse,
|
LinkResponse,
|
||||||
|
RegistrationListResponse,
|
||||||
|
RegistrationResponse,
|
||||||
|
RFStatsListResponse,
|
||||||
|
RFStatsResponse,
|
||||||
UnknownClientListResponse,
|
UnknownClientListResponse,
|
||||||
UnknownClientResponse,
|
UnknownClientResponse,
|
||||||
)
|
)
|
||||||
@@ -180,3 +184,92 @@ async def get_unknown_clients(
|
|||||||
]
|
]
|
||||||
|
|
||||||
return UnknownClientListResponse(items=items, total=len(items))
|
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))
|
||||||
|
|||||||
@@ -566,6 +566,178 @@ export const sitesApi = {
|
|||||||
.then((r) => r.data),
|
.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<SectorListResponse>(`/api/tenants/${tenantId}/sites/${siteId}/sectors`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
create: (tenantId: string, siteId: string, data: SectorCreate) =>
|
||||||
|
api
|
||||||
|
.post<SectorResponse>(`/api/tenants/${tenantId}/sites/${siteId}/sectors`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
update: (tenantId: string, siteId: string, sectorId: string, data: SectorUpdate) =>
|
||||||
|
api
|
||||||
|
.put<SectorResponse>(
|
||||||
|
`/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<LinkListResponse>(`/api/tenants/${tenantId}/links`, { params })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getSiteLinks: (tenantId: string, siteId: string) =>
|
||||||
|
api
|
||||||
|
.get<LinkListResponse>(`/api/tenants/${tenantId}/sites/${siteId}/links`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getDeviceLinks: (tenantId: string, deviceId: string) =>
|
||||||
|
api
|
||||||
|
.get<LinkListResponse>(`/api/tenants/${tenantId}/devices/${deviceId}/links`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getDeviceRegistrations: (tenantId: string, deviceId: string) =>
|
||||||
|
api
|
||||||
|
.get<RegistrationListResponse>(
|
||||||
|
`/api/tenants/${tenantId}/devices/${deviceId}/registrations`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getDeviceRFStats: (tenantId: string, deviceId: string) =>
|
||||||
|
api
|
||||||
|
.get<RFStatsListResponse>(
|
||||||
|
`/api/tenants/${tenantId}/devices/${deviceId}/rf-stats`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getUnknownClients: (tenantId: string, deviceId: string) =>
|
||||||
|
api
|
||||||
|
.get<UnknownClientListResponse>(
|
||||||
|
`/api/tenants/${tenantId}/devices/${deviceId}/unknown-clients`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Metrics ──────────────────────────────────────────────────────────────────
|
// ─── Metrics ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface HealthMetricPoint {
|
export interface HealthMetricPoint {
|
||||||
|
|||||||
Reference in New Issue
Block a user