refactor code, new db tables, show AP info on UI
build-and-push / build (push) Failing after 34s

This commit is contained in:
2026-08-01 02:44:50 +05:00
parent a6d4fa1f6d
commit db140912f5
13 changed files with 1103 additions and 660 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ FreeRADIUS schema and a web admin portal for it.
| Dir | What | Stack | | Dir | What | Stack |
|-------------|----------------------------------------|--------------------------------------------| |-------------|----------------------------------------|--------------------------------------------|
| `backend/` | RESTful CRUD over FreeRADIUS tables | FastAPI, SQLAlchemy 2.0, PyMySQL, Pydantic v2 | | `backend/` | RESTful CRUD over FreeRADIUS tables | FastAPI, SQLAlchemy 2.0, PyMySQL, Pydantic v2 |
| `frontend/` | Admin UI for devices and VLANs | React 19, Vite, TypeScript, Tailwind v4 | | `frontend/` | Admin UI for clients, devices and VLANs | React 19, Vite, TypeScript, Tailwind v4 |
Auth across both is a shared secret sent in the **`X-API-Key`** header — the Auth across both is a shared secret sent in the **`X-API-Key`** header — the
frontend collects the key on a login screen and the backend validates it. frontend collects the key on a login screen and the backend validates it.
+110 -38
View File
@@ -4,6 +4,17 @@ A FastAPI service exposing RESTful CRUD over the FreeRADIUS MySQL/MariaDB schema
(`customers`, `radcheck`, `radreply`, `radgroupreply`/vlans, `radusergroup`, `nas`, (`customers`, `radcheck`, `radreply`, `radgroupreply`/vlans, `radusergroup`, `nas`,
plus read-only `radacct`, `radpostauth`, `nasreload`). plus read-only `radacct`, `radpostauth`, `nasreload`).
Two endpoints are **logical views** layered over that schema rather than raw tables:
- **Clients** (`/client`) — customer devices keyed by MAC, spanning `radcheck` +
`radusergroup` + `customers` (status), with human metadata (name/phone/alias) in
the standalone **`radadmin_clients`** table.
- **Devices** (`/device`) — the NAS/AP boxes seen in `radacct`, keyed by **AP MAC**
(from `calledstationid`), with a human alias in the standalone
**`radadmin_devices`** table.
The two `radadmin_*` tables hold only human labels — RADIUS never reads them.
## Stack ## Stack
- **FastAPI** + **Uvicorn** (ASGI) - **FastAPI** + **Uvicorn** (ASGI)
- **SQLAlchemy 2.0** ORM + **PyMySQL** driver - **SQLAlchemy 2.0** ORM + **PyMySQL** driver
@@ -57,8 +68,9 @@ Missing/wrong key → `401`. `/`, `/health`, and `/docs` are open.
## UI integration guide ## UI integration guide
For a management portal the two primary resources are **Devices** (`/device`) and For a management portal the primary resources are **Clients** (`/client`),
**VLANs** (`/vlan`). Everything else is lower-level raw-table access. **Devices** (`/device`), and **VLANs** (`/vlan`). Everything else is lower-level
raw-table access.
**Base URL (staging):** `http://10.0.1.235:8000` **Base URL (staging):** `http://10.0.1.235:8000`
**Every request:** header `X-API-Key: <API_KEY>` (except `/health`). **Every request:** header `X-API-Key: <API_KEY>` (except `/health`).
@@ -66,7 +78,7 @@ For a management portal the two primary resources are **Devices** (`/device`) an
### Response shapes ### Response shapes
`GET /device/`**paginated** envelope: `GET /client/`**paginated** envelope:
```json ```json
{ {
@@ -80,13 +92,20 @@ For a management portal the two primary resources are **Devices** (`/device`) an
} }
``` ```
`GET /device/{mac}`, `POST /device/add`, `POST /device/edit` — a single device object: `GET /client/{mac}`, `POST /client/add`, `POST /client/edit` — a single client object:
```json ```json
{ "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid", { "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid",
"name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" } "name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" }
``` ```
`GET /device/`**plain array** of NAS devices (not paginated):
```json
[ { "ap_mac": "3C-52-A1-93-06-FC", "nasipaddress": "192.168.1.102",
"ssids": ["VSARLINK", "Guest"], "alias": "Lobby AP" } ]
```
`GET /vlan/`**plain array** (not paginated): `GET /vlan/`**plain array** (not paginated):
```json ```json
@@ -102,7 +121,7 @@ For a management portal the two primary resources are **Devices** (`/device`) an
Application errors (`400`, `401`, `404`, `409`) return a **string** detail: Application errors (`400`, `401`, `404`, `409`) return a **string** detail:
```json ```json
{ "detail": "Device 'AA-BB-CC-DD-EE-11' already exists" } { "detail": "Client 'AA-BB-CC-DD-EE-11' already exists" }
``` ```
Validation errors (`422`, bad/missing fields) return a FastAPI **array** detail: Validation errors (`422`, bad/missing fields) return a FastAPI **array** detail:
@@ -116,12 +135,15 @@ each entry's `msg` (and `loc`) for field-level messages.
### Typical portal flows ### Typical portal flows
- **Provision a device:** `POST /device/add` with `mac_address`, `group` (an - **Provision a client:** `POST /client/add` with `mac_address`, `group` (an
existing VLAN alias), `name`, `phone`, optional `alias`. Handle `400` (group existing VLAN alias), `name`, `phone`, optional `alias`. Handle `400` (group
missing), `409` (MAC exists), `422` (bad MAC / missing name·phone). missing), `409` (MAC exists), `422` (bad MAC / missing name·phone).
- **Change plan state:** `POST /device/edit` `{mac_address, status}`. - **Change plan state:** `POST /client/edit` `{mac_address, status}`.
- **Move to another VLAN:** `POST /device/edit` `{mac_address, group}`. - **Move to another VLAN:** `POST /client/edit` `{mac_address, group}`.
- **Bulk import clients:** `POST /client/import` `{devices:[...], dry_run}` — see
the Clients section.
- **Populate a VLAN dropdown:** `GET /vlan/` → map `alias` (value sent as `group`). - **Populate a VLAN dropdown:** `GET /vlan/` → map `alias` (value sent as `group`).
- **Label a NAS device:** `GET /device/` to list them, `PUT /device/{ip}` `{alias}`.
## Endpoints ## Endpoints
@@ -141,7 +163,8 @@ per-resource filters:
| Group check | `/radgroupcheck` | GET, POST, PUT, DELETE | `groupname`, `attribute` | | Group check | `/radgroupcheck` | GET, POST, PUT, DELETE | `groupname`, `attribute` |
| Group reply | `/radgroupreply` | GET, POST, PUT, DELETE | `groupname`, `attribute` | | Group reply | `/radgroupreply` | GET, POST, PUT, DELETE | `groupname`, `attribute` |
| **VLANs** | `/vlan` | see below | — | | **VLANs** | `/vlan` | see below | — |
| **Devices** | `/device` | see below | — | | **Clients** | `/client` | see below | — |
| **Devices** | `/device` | GET (list), PUT (alias) | — |
| User↔group | `/radusergroup` | GET, POST, PUT, DELETE | `username`, `groupname` | | User↔group | `/radusergroup` | GET, POST, PUT, DELETE | `username`, `groupname` |
| Accounting | `/radacct` | GET (read-only) | `username`, `nasipaddress`, `active` | | Accounting | `/radacct` | GET (read-only) | `username`, `nasipaddress`, `active` |
| Post-auth log | `/radpostauth` | GET (read-only) | `username`, `reply` | | Post-auth log | `/radpostauth` | GET (read-only) | `username`, `reply` |
@@ -169,37 +192,65 @@ and `Tunnel-Private-Group-Id=<vlanid>`.
- Renaming updates `radgroupreply.groupname` only; if you also map users to groups - Renaming updates `radgroupreply.groupname` only; if you also map users to groups
in `radusergroup`, update those separately. in `radusergroup`, update those separately.
### Devices — `/device` ### Clients — `/client`
A device is a client identified by its MAC address (used as the RADIUS `username`). A client is identified by its MAC address (used as the RADIUS `username`). One
One device spans three tables: `radcheck` (MAC = password), `radusergroup` (group client spans four tables: `radcheck` (MAC = password), `radusergroup` (group
membership), and `customers` (status + human metadata). MAC input is normalized to membership), `customers` (status), and `radadmin_clients` (human metadata). MAC
uppercase, hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and lowercase are accepted. input is normalized to uppercase, hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and
lowercase are accepted.
The `customers` table also carries **human-only metadata that RADIUS never reads**: The **`radadmin_clients`** table carries **human-only metadata that RADIUS never
`name`, `phone`, and `device_alias`. These are collected at device creation. reads** — `name`, `phone`, and `alias`, keyed by `mac_address`. These are collected
at client creation.
| Action | Request | | Action | Request |
|------------------|-----------------------------------------------------| |------------------|-----------------------------------------------------|
| List devices | `GET /device/``{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` | | List clients | `GET /client/``{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` |
| Get one device | `GET /device/{mac_address}` | | Get one client | `GET /client/{mac_address}` |
| Add a device | `POST /device/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}` | | Add a client | `POST /client/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}` |
| Edit a device | `POST /device/edit` body `{"mac_address":"...", group?, status?, name?, phone?, alias?}` | | Edit a client | `POST /client/edit` body `{"mac_address":"...", group?, status?, name?, phone?, alias?}` |
| Delete a device | `DELETE /device/{mac_address}` (removes all 3 rows) | | Import clients | `POST /client/import` body `{"devices":[{...}], "dry_run":true}` (bulk CSV import) |
| Delete a client | `DELETE /client/{mac_address}` (removes all rows) |
On **add**, `name` and `phone` are **required**; `alias` is **optional**. They are On **add**, `name` and `phone` are **required**; `alias` is **optional**. They are
stored in `customers.name`, `customers.phone`, `customers.device_alias` and returned stored in `radadmin_clients` (`name`, `phone`, `alias`) and returned on every client
on every device response (`alias` mirrors the `device_alias` column). response.
- **Add** inserts a `radcheck` password (`Cleartext-Password := MAC`), a - **Add** inserts a `radcheck` password (`Cleartext-Password := MAC`), a
`radusergroup` row (`priority 1`), and a `customers` row (`status = paid`). The `radusergroup` row (`priority 1`), a `customers` row (`status = paid`), and a
`group` must already exist in `radgroupreply` or you get `400`. `radadmin_clients` row (name/phone/alias). The `group` must already exist in
`radgroupreply` or you get `400`.
- **Edit** accepts any subset of `group`, `status`, `name`, `phone`, `alias` (at - **Edit** accepts any subset of `group`, `status`, `name`, `phone`, `alias` (at
least one required); omitted fields are left unchanged. A new `group` must exist least one required); omitted fields are left unchanged. A new `group` must exist
in `radgroupreply` (`400` otherwise). `status` must be one of `new` / `paid` / in `radgroupreply` (`400` otherwise). `status` must be one of `new` / `paid` /
`unpaid`. In the DB the group is stored in the `radusergroup.groupname` column and `unpaid`. In the DB the group is stored in the `radusergroup.groupname` column,
`alias` in `customers.device_alias`. `status` in `customers.status`, and `name`/`phone`/`alias` in `radadmin_clients`.
- Adding a device whose MAC already exists → `409`. - Adding a client whose MAC already exists → `409`.
- **Import** validates each row with the same rules as `add`; with `dry_run:true`
nothing is written and it returns `{total, valid, created:0, dry_run, errors:[{row,
mac, detail}]}` for a preview. With `dry_run:false` the valid rows are inserted
best-effort (invalid rows reported, not fatal). The JSON field is `devices` (rows).
### Devices — `/device`
A device here is a **NAS/AP box**, keyed by its **AP MAC** — not a customer MAC
(those are Clients). The list is derived from `radacct.calledstationid`
(`<AP-MAC>:<SSID>`): rows are grouped by the AP MAC, so one physical AP is a single
entry even when it broadcasts several SSIDs. Each entry aggregates every `ssid` seen
and the NAS IP(s) it reported from. An editable human `alias` is stored in the
standalone **`radadmin_devices`** table (keyed by AP MAC), which FreeRADIUS never reads.
| Action | Request |
|------------------|-----------------------------------------------------|
| List devices | `GET /device/``[{ap_mac, nasipaddress, ssids:[...], alias}]` |
| Set/clear alias | `PUT /device/{ap_mac}` body `{"alias":"Lobby AP"}` (`alias:null` clears) |
- List is a **plain array** (not paginated), ordered by AP MAC.
- `nasipaddress` is the NAS IP(s) the AP reports from (comma-joined if more than one);
`ssids` is the full list of SSIDs seen for that AP MAC.
- `PUT` upserts the `radadmin_devices` row for that AP MAC — no row need pre-exist;
the MAC is normalized to uppercase-hyphen form.
## Examples ## Examples
@@ -231,27 +282,44 @@ curl http://10.0.1.235:8000/vlan/55 \
-X DELETE \ -X DELETE \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# List devices # List clients
curl http://10.0.1.235:8000/device/ \ curl http://10.0.1.235:8000/client/ \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# Add a device (MAC + existing group + name/phone required, alias optional) # Add a client (MAC + existing group + name/phone required, alias optional)
curl http://10.0.1.235:8000/device/add \ curl http://10.0.1.235:8000/client/add \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}' -s | jq -d '{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}' -s | jq
# Edit a device (any subset of group/status/name/phone/alias) # Edit a client (any subset of group/status/name/phone/alias)
curl http://10.0.1.235:8000/device/edit \ curl http://10.0.1.235:8000/client/edit \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"mac_address":"14-99-3E-74-CB-7F","status":"unpaid","alias":"Living Room TV"}' -s | jq -d '{"mac_address":"14-99-3E-74-CB-7F","status":"unpaid","alias":"Living Room TV"}' -s | jq
# Delete a device # Bulk-import clients (dry-run — preview only, nothing written)
curl http://10.0.1.235:8000/device/14-99-3E-74-CB-7F \ curl http://10.0.1.235:8000/client/import \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"dry_run":true,"devices":[{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali","phone":"7712345"}]}' -s | jq
# Delete a client
curl http://10.0.1.235:8000/client/14-99-3E-74-CB-7F \
-X DELETE \ -X DELETE \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# List NAS devices (IPs seen in radacct, with alias/AP-MAC/SSID)
curl http://10.0.1.235:8000/device/ \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# Set a device alias by AP MAC (PUT; send {"alias":null} to clear)
curl http://10.0.1.235:8000/device/3C-52-A1-93-06-FC \
-X PUT \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"alias":"Lobby AP"}' -s | jq
# Create a customer # Create a customer
curl http://10.0.1.235:8000/customers \ curl http://10.0.1.235:8000/customers \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
@@ -282,7 +350,7 @@ curl http://10.0.1.235:8000/health -s | jq
| 204 | Deleted (no content) | | 204 | Deleted (no content) |
| 400 | Bad request (e.g. referenced group/VLAN doesn't exist) | | 400 | Bad request (e.g. referenced group/VLAN doesn't exist) |
| 401 | Missing/invalid `X-API-Key` | | 401 | Missing/invalid `X-API-Key` |
| 404 | Row / device / VLAN not found | | 404 | Row / client / device / VLAN not found |
| 409 | Duplicate / integrity conflict | | 409 | Duplicate / integrity conflict |
| 422 | Request body failed validation | | 422 | Request body failed validation |
@@ -297,7 +365,11 @@ app/
errors.py APIError + JSON handler errors.py APIError + JSON handler
crud.py generic list/get/create/update/delete helpers crud.py generic list/get/create/update/delete helpers
pagination.py Page envelope + limit/offset dependency pagination.py Page envelope + limit/offset dependency
models.py SQLAlchemy models (one per table) models.py SQLAlchemy models (incl. radadmin_clients, radadmin_devices)
schemas.py Pydantic request/response models schemas.py Pydantic request/response models
routers/ one module per resource routers/ one module per resource
client.py Clients — MAC view over radcheck+radusergroup+customers
device.py Devices — AP MACs from radacct + radadmin_devices alias
vlan.py VLANs — view over radgroupreply
... customers, nas, radcheck/reply, radgroup*, radacct, ...
``` ```
+2
View File
@@ -7,6 +7,7 @@ from .config import get_settings
from .database import engine from .database import engine
from .errors import APIError, api_error_handler from .errors import APIError, api_error_handler
from .routers import ( from .routers import (
client,
customers, customers,
device, device,
nas, nas,
@@ -69,6 +70,7 @@ for module in (
radgroupreply, radgroupreply,
radusergroup, radusergroup,
vlan, vlan,
client,
device, device,
radacct, radacct,
radpostauth, radpostauth,
+26 -1
View File
@@ -13,9 +13,34 @@ class Customer(Base):
username: Mapped[str] = mapped_column(String(64), nullable=False) username: Mapped[str] = mapped_column(String(64), nullable=False)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False) mac_address: Mapped[str] = mapped_column(String(17), nullable=False)
status: Mapped[str] = mapped_column(String(10), nullable=False, default="new") status: Mapped[str] = mapped_column(String(10), nullable=False, default="new")
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
class RadadminClient(Base):
"""Human-only client metadata (name/phone/alias), keyed by MAC. RADIUS ignores this."""
__tablename__ = "radadmin_clients"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False, unique=True)
name: Mapped[str | None] = mapped_column(String(128), nullable=True) name: Mapped[str | None] = mapped_column(String(128), nullable=True)
phone: Mapped[str | None] = mapped_column(String(32), nullable=True) phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
device_alias: Mapped[str | None] = mapped_column(String(64), nullable=True) alias: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
class RadadminDevice(Base):
"""Human alias for a NAS/AP box, keyed by its AP MAC. RADIUS never reads this."""
__tablename__ = "radadmin_devices"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False, unique=True)
alias: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime | None] = mapped_column( created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp() DateTime, nullable=True, server_default=func.current_timestamp()
) )
+239
View File
@@ -0,0 +1,239 @@
"""Client management — a logical view spanning three tables.
A "client" is identified by its MAC address, which is used verbatim as the RADIUS
``username``. One client touches three tables:
radcheck username = MAC, Cleartext-Password := MAC (auth)
radusergroup username = MAC, groupname = <group> (VLAN/group membership)
customers username = MAC, mac_address = MAC, status (billing/metadata)
This router hides that fan-out behind mac_address + group + status.
"""
from fastapi import APIRouter, Depends
from pydantic import ValidationError
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ..database import get_db
from ..errors import APIError
from ..models import Customer, RadadminClient, RadCheck, RadGroupReply, RadUserGroup
from ..pagination import Page, PageParams
from ..schemas import (
ClientCreate,
ClientEdit,
ClientImportError,
ClientImportRequest,
ClientImportResult,
ClientOut,
)
router = APIRouter(prefix="/client", tags=["client"])
def _group_exists(db: Session, group: str) -> bool:
stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == group).limit(1)
return db.execute(stmt).first() is not None
def _client_exists(db: Session, mac: str) -> bool:
stmt = select(Customer.id).where(Customer.username == mac).limit(1)
return db.execute(stmt).first() is not None
def _stage_client(db: Session, cli: ClientCreate) -> None:
"""Add a client's four rows to the session — radcheck + radusergroup (RADIUS),
customers (status), and radadmin_clients (name/phone/alias metadata).
Does not commit — the caller controls the transaction boundary.
"""
mac = cli.mac_address
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
db.add(RadUserGroup(username=mac, groupname=cli.group, priority=1))
db.add(Customer(username=mac, mac_address=mac, status="paid"))
db.add(RadadminClient(mac_address=mac, name=cli.name, phone=cli.phone, alias=cli.alias))
@router.get("/", response_model=Page[ClientOut])
def list_clients(page: PageParams = Depends(), db: Session = Depends(get_db)):
"""List clients — MAC, group and status, joined from customers + radusergroup."""
base = (
select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username)
)
total = db.execute(select(func.count()).select_from(Customer)).scalar_one()
rows = db.execute(base.order_by(Customer.id.desc()).limit(page.limit).offset(page.offset)).all()
items = [
ClientOut(mac_address=mac, group=gn, status=st, name=nm, phone=ph, alias=al)
for mac, gn, st, nm, ph, al in rows
]
return Page(total=int(total), limit=page.limit, offset=page.offset, items=items)
@router.get("/{mac_address}", response_model=ClientOut)
def get_client(mac_address: str, db: Session = Depends(get_db)):
mac = mac_address.strip().upper().replace(":", "-")
stmt = (
select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username)
.where(Customer.username == mac)
)
row = db.execute(stmt).first()
if row is None:
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
return ClientOut(
mac_address=row[0], group=row[1], status=row[2],
name=row[3], phone=row[4], alias=row[5],
)
@router.post("/add", response_model=ClientOut, status_code=201)
def add_client(payload: ClientCreate, db: Session = Depends(get_db)):
"""Register a client: create its radcheck, radusergroup and customer rows."""
mac = payload.mac_address
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found — create it first")
if _client_exists(db, mac):
raise APIError(status_code=409, detail=f"Client '{mac}' already exists")
_stage_client(db, payload)
db.commit()
return ClientOut(
mac_address=mac, group=payload.group, status="paid",
name=payload.name, phone=payload.phone, alias=payload.alias,
)
def _format_validation_error(exc: ValidationError) -> str:
"""Turn a pydantic ValidationError into a short, human message."""
parts = []
for err in exc.errors():
loc = ".".join(str(p) for p in err["loc"])
parts.append(f"{loc}: {err['msg']}" if loc else err["msg"])
return "; ".join(parts)
@router.post("/import", response_model=ClientImportResult)
def import_clients(payload: ClientImportRequest, db: Session = Depends(get_db)):
"""Bulk-import clients from parsed CSV rows.
Every row is validated with the same rules as ``/client/add`` (MAC/phone
normalization, required fields, group-exists, duplicate MAC — both against the
DB and within the file). Errors are collected per row rather than failing the
batch. With ``dry_run`` nothing is written, so the UI can preview and confirm;
otherwise the valid rows are inserted best-effort (each in its own commit).
"""
errors: list[ClientImportError] = []
valid: list[tuple[int, ClientCreate]] = []
seen_macs: set[str] = set()
for idx, raw in enumerate(payload.devices, start=1):
try:
cli = ClientCreate(**raw.model_dump())
except ValidationError as exc:
errors.append(ClientImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc)))
continue
mac = cli.mac_address
if mac in seen_macs:
errors.append(ClientImportError(row=idx, mac=mac, detail="Duplicate MAC within file"))
continue
if not _group_exists(db, cli.group):
errors.append(ClientImportError(row=idx, mac=mac, detail=f"Group '{cli.group}' not found"))
continue
if _client_exists(db, mac):
errors.append(ClientImportError(row=idx, mac=mac, detail=f"Client '{mac}' already exists"))
continue
seen_macs.add(mac)
valid.append((idx, cli))
created = 0
if not payload.dry_run:
for idx, cli in valid:
_stage_client(db, cli)
try:
db.commit()
created += 1
except IntegrityError:
db.rollback()
errors.append(ClientImportError(row=idx, mac=cli.mac_address, detail="Insert failed (integrity error)"))
return ClientImportResult(
total=len(payload.devices),
valid=len(valid),
created=created,
dry_run=payload.dry_run,
errors=sorted(errors, key=lambda e: e.row),
)
@router.post("/edit", response_model=ClientOut)
def edit_client(payload: ClientEdit, db: Session = Depends(get_db)):
"""Edit a client — any subset of group, status, name, phone, alias."""
mac = payload.mac_address
customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none()
if customer is None:
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
if payload.group is not None:
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found")
db.execute(
update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group)
)
if payload.status is not None:
customer.status = payload.status
if payload.name is not None or payload.phone is not None or payload.alias is not None:
meta = db.execute(
select(RadadminClient).where(RadadminClient.mac_address == mac)
).scalar_one_or_none()
if meta is None:
meta = RadadminClient(mac_address=mac)
db.add(meta)
if payload.name is not None:
meta.name = payload.name
if payload.phone is not None:
meta.phone = payload.phone
if payload.alias is not None:
meta.alias = payload.alias
db.commit()
group = db.execute(
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
).scalar_one_or_none()
meta = db.execute(
select(RadadminClient).where(RadadminClient.mac_address == mac)
).scalar_one_or_none()
return ClientOut(
mac_address=mac, group=group, status=customer.status,
name=meta.name if meta else None,
phone=meta.phone if meta else None,
alias=meta.alias if meta else None,
)
@router.delete("/{mac_address}", status_code=204)
def delete_client(mac_address: str, db: Session = Depends(get_db)):
"""Remove a client from radcheck, radusergroup and customers."""
mac = mac_address.strip().upper().replace(":", "-")
if not _client_exists(db, mac):
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
db.execute(delete(RadCheck).where(RadCheck.username == mac))
db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac))
db.execute(delete(Customer).where(Customer.username == mac))
db.execute(delete(RadadminClient).where(RadadminClient.mac_address == mac))
db.commit()
+75 -200
View File
@@ -1,221 +1,96 @@
"""Device management — a logical view spanning three tables. """Device management — the NAS/AP boxes seen in accounting.
A "device" is a client identified by its MAC address, which is used verbatim as Unlike *clients* (customer MACs), a "device" here is a piece of network gear
the RADIUS ``username``. One device touches three tables: identified by its **AP MAC** — the part before ':' in ``radacct.calledstationid``
(`<AP-MAC>:<SSID>`). Rows are grouped by that MAC, so one physical AP is a single
radcheck username = MAC, Cleartext-Password := MAC (auth) row even when it broadcasts several SSIDs (all SSIDs, and the NAS IP(s) it reports
radusergroup username = MAC, groupname = <group> (VLAN/group membership) from, are aggregated). An editable ``alias`` lives in the standalone
customers username = MAC, mac_address = MAC, status (billing/metadata) ``radadmin_devices`` table, keyed by AP MAC — FreeRADIUS never reads it.
This router hides that fan-out behind mac_address + group + status.
""" """
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import ValidationError from sqlalchemy import select
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..errors import APIError from ..models import RadadminDevice, RadAcct
from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup from ..schemas import DeviceAliasUpdate, DeviceOut
from ..pagination import Page, PageParams
from ..schemas import (
DeviceCreate,
DeviceEdit,
DeviceImportError,
DeviceImportRequest,
DeviceImportResult,
DeviceOut,
)
router = APIRouter(prefix="/device", tags=["device"]) router = APIRouter(prefix="/device", tags=["device"])
def _group_exists(db: Session, group: str) -> bool: def _norm_mac(mac: str) -> str:
stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == group).limit(1) """Canonical AP MAC — uppercase, hyphen-separated (matches client MAC form)."""
return db.execute(stmt).first() is not None return mac.strip().upper().replace(":", "-")
def _device_exists(db: Session, mac: str) -> bool: def _split_called(called: str | None) -> tuple[str | None, str | None]:
stmt = select(Customer.id).where(Customer.username == mac).limit(1) """radacct.calledstationid is ``<AP-MAC>:<SSID>`` — split into (mac, ssid).
return db.execute(stmt).first() is not None
The MAC is normalized to uppercase-hyphen; the SSID is left verbatim.
def _stage_device(db: Session, dev: DeviceCreate) -> None:
"""Add a device's three rows (radcheck, radusergroup, customers) to the session.
Does not commit — the caller controls the transaction boundary.
""" """
mac = dev.mac_address if not called:
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac)) return None, None
db.add(RadUserGroup(username=mac, groupname=dev.group, priority=1)) if ":" in called:
db.add(Customer( mac, ssid = called.split(":", 1)
username=mac, mac_address=mac, status="paid", return (_norm_mac(mac) or None), (ssid or None)
name=dev.name, phone=dev.phone, device_alias=dev.alias, return _norm_mac(called) or None, None
))
@router.get("/", response_model=Page[DeviceOut]) def _aliases(db: Session) -> dict[str, str | None]:
def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)): rows = db.execute(select(RadadminDevice.mac_address, RadadminDevice.alias)).all()
"""List devices — MAC, group and status, joined from customers + radusergroup.""" return {mac: alias for mac, alias in rows}
base = select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
Customer.name, Customer.phone, Customer.device_alias, def _devices(db: Session) -> dict[str, dict]:
).outerjoin(RadUserGroup, RadUserGroup.username == Customer.username) """Aggregate radacct into {ap_mac: {ips, ssids}} over distinct station pairs."""
total = db.execute(select(func.count()).select_from(Customer)).scalar_one() pairs = db.execute(
rows = db.execute(base.order_by(Customer.id.desc()).limit(page.limit).offset(page.offset)).all() select(RadAcct.nasipaddress, RadAcct.calledstationid).distinct()
items = [ ).all()
DeviceOut(mac_address=mac, group=gn, status=st, name=nm, phone=ph, alias=al) devices: dict[str, dict] = {}
for mac, gn, st, nm, ph, al in rows for ip, called in pairs:
mac, ssid = _split_called(called)
if not mac:
continue
d = devices.setdefault(mac, {"ips": set(), "ssids": set()})
if ip:
d["ips"].add(ip)
if ssid:
d["ssids"].add(ssid)
return devices
def _device_out(ap_mac: str, agg: dict | None, alias: str | None) -> DeviceOut:
agg = agg or {"ips": set(), "ssids": set()}
return DeviceOut(
ap_mac=ap_mac,
nasipaddress=", ".join(sorted(agg["ips"])) or None,
ssids=sorted(agg["ssids"]),
alias=alias,
)
@router.get("/", response_model=list[DeviceOut])
def list_devices(db: Session = Depends(get_db)):
"""List NAS/AP devices — one row per AP MAC, SSIDs and NAS IPs aggregated."""
devices = _devices(db)
aliases = _aliases(db)
return [
_device_out(mac, devices[mac], aliases.get(mac))
for mac in sorted(devices)
] ]
return Page(total=int(total), limit=page.limit, offset=page.offset, items=items)
@router.get("/{mac_address}", response_model=DeviceOut) @router.put("/{ap_mac}", response_model=DeviceOut)
def get_device(mac_address: str, db: Session = Depends(get_db)): def set_alias(ap_mac: str, payload: DeviceAliasUpdate, db: Session = Depends(get_db)):
mac = mac_address.strip().upper().replace(":", "-") """Set (or clear) the alias for an AP MAC — upserts into radadmin_devices."""
stmt = ( mac = _norm_mac(ap_mac)
select( row = db.execute(
Customer.mac_address, RadUserGroup.groupname, Customer.status, select(RadadminDevice).where(RadadminDevice.mac_address == mac)
Customer.name, Customer.phone, Customer.device_alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.where(Customer.username == mac)
)
row = db.execute(stmt).first()
if row is None:
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
return DeviceOut(
mac_address=row[0], group=row[1], status=row[2],
name=row[3], phone=row[4], alias=row[5],
)
@router.post("/add", response_model=DeviceOut, status_code=201)
def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
"""Register a device: create its radcheck, radusergroup and customer rows."""
mac = payload.mac_address
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found — create it first")
if _device_exists(db, mac):
raise APIError(status_code=409, detail=f"Device '{mac}' already exists")
_stage_device(db, payload)
db.commit()
return DeviceOut(
mac_address=mac, group=payload.group, status="paid",
name=payload.name, phone=payload.phone, alias=payload.alias,
)
def _format_validation_error(exc: ValidationError) -> str:
"""Turn a pydantic ValidationError into a short, human message."""
parts = []
for err in exc.errors():
loc = ".".join(str(p) for p in err["loc"])
parts.append(f"{loc}: {err['msg']}" if loc else err["msg"])
return "; ".join(parts)
@router.post("/import", response_model=DeviceImportResult)
def import_devices(payload: DeviceImportRequest, db: Session = Depends(get_db)):
"""Bulk-import devices from parsed CSV rows.
Every row is validated with the same rules as ``/device/add`` (MAC/phone
normalization, required fields, group-exists, duplicate MAC — both against the
DB and within the file). Errors are collected per row rather than failing the
batch. With ``dry_run`` nothing is written, so the UI can preview and confirm;
otherwise the valid rows are inserted best-effort (each in its own commit).
"""
errors: list[DeviceImportError] = []
valid: list[tuple[int, DeviceCreate]] = []
seen_macs: set[str] = set()
for idx, raw in enumerate(payload.devices, start=1):
try:
dev = DeviceCreate(**raw.model_dump())
except ValidationError as exc:
errors.append(DeviceImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc)))
continue
mac = dev.mac_address
if mac in seen_macs:
errors.append(DeviceImportError(row=idx, mac=mac, detail="Duplicate MAC within file"))
continue
if not _group_exists(db, dev.group):
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Group '{dev.group}' not found"))
continue
if _device_exists(db, mac):
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Device '{mac}' already exists"))
continue
seen_macs.add(mac)
valid.append((idx, dev))
created = 0
if not payload.dry_run:
for idx, dev in valid:
_stage_device(db, dev)
try:
db.commit()
created += 1
except IntegrityError:
db.rollback()
errors.append(DeviceImportError(row=idx, mac=dev.mac_address, detail="Insert failed (integrity error)"))
return DeviceImportResult(
total=len(payload.devices),
valid=len(valid),
created=created,
dry_run=payload.dry_run,
errors=sorted(errors, key=lambda e: e.row),
)
@router.post("/edit", response_model=DeviceOut)
def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)):
"""Edit a device — any subset of group, status, name, phone, alias."""
mac = payload.mac_address
customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none()
if customer is None:
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
if payload.group is not None:
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found")
db.execute(
update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group)
)
if payload.status is not None:
customer.status = payload.status
if payload.name is not None:
customer.name = payload.name
if payload.phone is not None:
customer.phone = payload.phone
if payload.alias is not None:
customer.device_alias = payload.alias
db.commit()
group = db.execute(
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
return DeviceOut( if row is None:
mac_address=mac, group=group, status=customer.status, db.add(RadadminDevice(mac_address=mac, alias=payload.alias))
name=customer.name, phone=customer.phone, alias=customer.device_alias, else:
) row.alias = payload.alias
@router.delete("/{mac_address}", status_code=204)
def delete_device(mac_address: str, db: Session = Depends(get_db)):
"""Remove a device from radcheck, radusergroup and customers."""
mac = mac_address.strip().upper().replace(":", "-")
if not _device_exists(db, mac):
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
db.execute(delete(RadCheck).where(RadCheck.username == mac))
db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac))
db.execute(delete(Customer).where(Customer.username == mac))
db.commit() db.commit()
return _device_out(mac, _devices(db).get(mac), payload.alias)
+27 -19
View File
@@ -10,13 +10,12 @@ class ORMModel(BaseModel):
# ---------- customers ---------- # ---------- customers ----------
# Human metadata (name/phone/alias) lives in radadmin_clients now — customers holds
# only the RADIUS-adjacent identity + billing status.
class CustomerBase(BaseModel): class CustomerBase(BaseModel):
username: str = Field(max_length=64) username: str = Field(max_length=64)
mac_address: str = Field(max_length=17) mac_address: str = Field(max_length=17)
status: Literal["new", "paid", "unpaid"] = "new" status: Literal["new", "paid", "unpaid"] = "new"
name: str | None = Field(default=None, max_length=128)
phone: str | None = Field(default=None, max_length=32)
device_alias: str | None = Field(default=None, max_length=64)
class CustomerCreate(CustomerBase): class CustomerCreate(CustomerBase):
@@ -27,9 +26,6 @@ class CustomerUpdate(BaseModel):
username: str | None = Field(default=None, max_length=64) username: str | None = Field(default=None, max_length=64)
mac_address: str | None = Field(default=None, max_length=17) mac_address: str | None = Field(default=None, max_length=17)
status: Literal["new", "paid", "unpaid"] | None = None status: Literal["new", "paid", "unpaid"] | None = None
name: str | None = Field(default=None, max_length=128)
phone: str | None = Field(default=None, max_length=32)
device_alias: str | None = Field(default=None, max_length=64)
class CustomerOut(ORMModel, CustomerBase): class CustomerOut(ORMModel, CustomerBase):
@@ -130,7 +126,7 @@ class VlanEdit(BaseModel):
alias: str = Field(min_length=1, max_length=64, description="New alias (groupname)") alias: str = Field(min_length=1, max_length=64, description="New alias (groupname)")
# ---------- devices (logical view over radcheck + radusergroup + customers) ---------- # ---------- clients (logical view over radcheck + radusergroup + customers) ----------
_MAC_RE = r"^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$" _MAC_RE = r"^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$"
@@ -157,16 +153,16 @@ def _normalize_phone(raw: str) -> str:
raise ValueError(_PHONE_ERROR) raise ValueError(_PHONE_ERROR)
class DeviceOut(BaseModel): class ClientOut(BaseModel):
mac_address: str mac_address: str
group: str | None = None # radusergroup.groupname group: str | None = None # radusergroup.groupname
status: str | None = None # customers.status status: str | None = None # customers.status
name: str | None = None # customers.name (human metadata) name: str | None = None # customers.name (human metadata)
phone: str | None = None # customers.phone (human metadata) phone: str | None = None # customers.phone (human metadata)
alias: str | None = None # customers.device_alias (human metadata) alias: str | None = None # radadmin_clients.alias (human metadata)
class DeviceCreate(BaseModel): class ClientCreate(BaseModel):
mac_address: str = Field(pattern=_MAC_RE, max_length=17) mac_address: str = Field(pattern=_MAC_RE, max_length=17)
group: str = Field(min_length=1, max_length=64) group: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=128, description="Customer name (metadata, ignored by RADIUS)") name: str = Field(min_length=1, max_length=128, description="Customer name (metadata, ignored by RADIUS)")
@@ -184,7 +180,7 @@ class DeviceCreate(BaseModel):
return _normalize_phone(v) return _normalize_phone(v)
class DeviceEdit(BaseModel): class ClientEdit(BaseModel):
mac_address: str = Field(pattern=_MAC_RE, max_length=17) mac_address: str = Field(pattern=_MAC_RE, max_length=17)
group: str | None = Field(default=None, max_length=64) group: str | None = Field(default=None, max_length=64)
status: Literal["new", "paid", "unpaid"] | None = None status: Literal["new", "paid", "unpaid"] | None = None
@@ -209,11 +205,11 @@ class DeviceEdit(BaseModel):
return self return self
# ---------- device CSV import ---------- # ---------- client CSV import ----------
class DeviceImportRow(BaseModel): class ClientImportRow(BaseModel):
"""A single CSV row — lenient so one bad row never 422s the whole batch. """A single CSV row — lenient so one bad row never 422s the whole batch.
Each row is re-validated with ``DeviceCreate`` inside the router so failures Each row is re-validated with ``ClientCreate`` inside the router so failures
are collected per-row instead of rejecting the entire request. are collected per-row instead of rejecting the entire request.
""" """
@@ -224,23 +220,35 @@ class DeviceImportRow(BaseModel):
alias: str | None = None alias: str | None = None
class DeviceImportRequest(BaseModel): class ClientImportRequest(BaseModel):
devices: list[DeviceImportRow] devices: list[ClientImportRow]
dry_run: bool = Field(default=False, description="Validate only; commit nothing") dry_run: bool = Field(default=False, description="Validate only; commit nothing")
class DeviceImportError(BaseModel): class ClientImportError(BaseModel):
row: int # 1-based index within the submitted rows row: int # 1-based index within the submitted rows
mac: str | None = None mac: str | None = None
detail: str detail: str
class DeviceImportResult(BaseModel): class ClientImportResult(BaseModel):
total: int # rows submitted total: int # rows submitted
valid: int # rows that passed validation valid: int # rows that passed validation
created: int # rows actually inserted (0 on dry_run) created: int # rows actually inserted (0 on dry_run)
dry_run: bool dry_run: bool
errors: list[DeviceImportError] errors: list[ClientImportError]
# ---------- devices (NAS/AP boxes seen in radacct, aliased via radadmin_devices) ----------
class DeviceOut(BaseModel):
ap_mac: str # calledstationid before ':' — the device key
nasipaddress: str | None = None # NAS IP(s) this AP reports from (comma-joined)
ssids: list[str] = [] # every SSID seen for this AP MAC
alias: str | None = None # radadmin_devices.alias (human label)
class DeviceAliasUpdate(BaseModel):
alias: str | None = Field(default=None, max_length=64)
# ---------- radusergroup ---------- # ---------- radusergroup ----------
+19 -9
View File
@@ -1,8 +1,9 @@
# radui — RADIUS Admin Portal # radui — RADIUS Admin Portal
Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **devices** and Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **clients**
**VLANs** without touching SQL. Auth is an **API key** entered on a login screen and (customer MACs), **devices** (NAS/AP boxes seen in accounting), and **VLANs**
stored in the browser (`localStorage`), sent as `X-API-Key` on every request. without touching SQL. Auth is an **API key** entered on a login screen and stored in
the browser (`localStorage`), sent as `X-API-Key` on every request.
## Stack ## Stack
@@ -55,19 +56,28 @@ src/
main.tsx providers (router, auth, toaster) main.tsx providers (router, auth, toaster)
App.tsx auth gate + nav + routes App.tsx auth gate + nav + routes
auth/auth.tsx API-key auth context (login/logout, 401 handling) auth/auth.tsx API-key auth context (login/logout, 401 handling)
lib/api.ts typed API client (device + vlan) + error handling lib/api.ts typed API client (client + device + vlan) + error handling
lib/utils.ts cn() helper lib/utils.ts cn() helper
pages/ pages/
Login.tsx API-key login screen Login.tsx API-key login screen
Devices.tsx device list + add/edit/delete dialogs Clients.tsx client list + add/edit/delete + CSV import/export dialogs
Devices.tsx NAS device list + editable alias
Vlans.tsx VLAN list + add/rename/delete dialogs Vlans.tsx VLAN list + add/rename/delete dialogs
components/ui/ button, input, label, dialog, select, table, card, badge, sonner components/
ClientImportExport.tsx CSV import (preview/confirm) + export dialog
ui/ button, input, label, dialog, select, table, card, badge, sonner
``` ```
## Notes ## Notes
- **Add device** requires MAC, group (VLAN), name, phone; alias optional. The group - **Clients** — add requires MAC, group (VLAN), name, phone; alias optional. The
dropdown is populated from `GET /vlan/`. group dropdown is populated from `GET /vlan/`. Edit changes any subset of group,
- **Edit device** can change group, status, name, phone, alias (any subset). status, name, phone, alias.
- **CSV import** parses the file client-side, does a dry-run against
`POST /client/import` to preview valid/error rows, and only writes on confirm.
Export downloads all clients as CSV.
- **Devices** — lists NAS/AP boxes from accounting, one row per **AP MAC** (with its
NAS IP and all SSIDs — extra SSIDs collapse to `SSID1 (and N more…)` on hover). The
only editable field is the human `alias` (`PUT /device/{ap_mac}`).
- Validation/`4xx` errors from the API surface as toasts with the server's message - Validation/`4xx` errors from the API surface as toasts with the server's message
(422 field errors are flattened to `field: message`). (422 field errors are flattened to `field: message`).
+7 -2
View File
@@ -1,8 +1,9 @@
import { NavLink, Navigate, Route, Routes } from 'react-router-dom' import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { LogOut, Moon, Router, Sun, Wifi } from 'lucide-react' import { LogOut, Moon, Router, Sun, Users, Wifi } from 'lucide-react'
import { useAuth } from '@/auth/auth' import { useAuth } from '@/auth/auth'
import { useTheme } from '@/lib/theme' import { useTheme } from '@/lib/theme'
import { Login } from '@/pages/Login' import { Login } from '@/pages/Login'
import { Clients } from '@/pages/Clients'
import { Devices } from '@/pages/Devices' import { Devices } from '@/pages/Devices'
import { Vlans } from '@/pages/Vlans' import { Vlans } from '@/pages/Vlans'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -39,6 +40,9 @@ function Nav() {
<Wifi className="h-5 w-5 text-primary" /> <Wifi className="h-5 w-5 text-primary" />
RADIUS Admin RADIUS Admin
</div> </div>
<NavLink to="/clients" className={linkClass}>
<Users className="h-4 w-4" /> Clients
</NavLink>
<NavLink to="/devices" className={linkClass}> <NavLink to="/devices" className={linkClass}>
<Router className="h-4 w-4" /> Devices <Router className="h-4 w-4" /> Devices
</NavLink> </NavLink>
@@ -66,9 +70,10 @@ export default function App() {
<Nav /> <Nav />
<main className="mx-auto max-w-6xl px-4 py-6"> <main className="mx-auto max-w-6xl px-4 py-6">
<Routes> <Routes>
<Route path="/clients" element={<Clients />} />
<Route path="/devices" element={<Devices />} /> <Route path="/devices" element={<Devices />} />
<Route path="/vlans" element={<Vlans />} /> <Route path="/vlans" element={<Vlans />} />
<Route path="*" element={<Navigate to="/devices" replace />} /> <Route path="*" element={<Navigate to="/clients" replace />} />
</Routes> </Routes>
</main> </main>
</div> </div>
@@ -5,9 +5,9 @@ import { toast } from 'sonner'
import { import {
api, api,
ApiError, ApiError,
type Device, type Client,
type DeviceImportResult, type ClientImportResult,
type DeviceImportRow, type ClientImportRow,
} from '@/lib/api' } from '@/lib/api'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -40,7 +40,7 @@ function downloadCsv(filename: string, csv: string) {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
function parseCsv(file: File): Promise<DeviceImportRow[]> { function parseCsv(file: File): Promise<ClientImportRow[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
Papa.parse<Record<string, string>>(file, { Papa.parse<Record<string, string>>(file, {
header: true, header: true,
@@ -64,10 +64,10 @@ function parseCsv(file: File): Promise<DeviceImportRow[]> {
}) })
} }
export function DeviceImportExport({ onImported }: { onImported: () => void }) { export function ClientImportExport({ onImported }: { onImported: () => void }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [rows, setRows] = useState<DeviceImportRow[] | null>(null) const [rows, setRows] = useState<ClientImportRow[] | null>(null)
const [preview, setPreview] = useState<DeviceImportResult | null>(null) const [preview, setPreview] = useState<ClientImportResult | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
@@ -92,7 +92,7 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
toast.error('No rows found in that CSV') toast.error('No rows found in that CSV')
return return
} }
const result = await api.devices.import(parsed, true) // dry run const result = await api.clients.import(parsed, true) // dry run
setRows(parsed) setRows(parsed)
setPreview(result) setPreview(result)
} catch (err) { } catch (err) {
@@ -108,10 +108,10 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
if (!rows) return if (!rows) return
setBusy(true) setBusy(true)
try { try {
const res = await api.devices.import(rows, false) const res = await api.clients.import(rows, false)
const skipped = res.errors.length const skipped = res.errors.length
toast.success( toast.success(
`Imported ${res.created} device${res.created === 1 ? '' : 's'}` + `Imported ${res.created} client${res.created === 1 ? '' : 's'}` +
(skipped ? `, ${skipped} skipped` : ''), (skipped ? `, ${skipped} skipped` : ''),
) )
onImported() onImported()
@@ -127,11 +127,11 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
async function exportAll() { async function exportAll() {
setExporting(true) setExporting(true)
try { try {
const all: Device[] = [] const all: Client[] = []
const limit = 500 const limit = 500
let offset = 0 let offset = 0
for (;;) { for (;;) {
const page = await api.devices.list(limit, offset) const page = await api.clients.list(limit, offset)
all.push(...page.items) all.push(...page.items)
offset += page.items.length offset += page.items.length
if (page.items.length === 0 || all.length >= page.total) break if (page.items.length === 0 || all.length >= page.total) break
@@ -147,8 +147,8 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
d.status ?? '', d.status ?? '',
]), ]),
}) })
downloadCsv('devices.csv', csv) downloadCsv('clients.csv', csv)
toast.success(`Exported ${all.length} device${all.length === 1 ? '' : 's'}`) toast.success(`Exported ${all.length} client${all.length === 1 ? '' : 's'}`)
} catch (err) { } catch (err) {
if (!(err instanceof ApiError && err.status === 401)) if (!(err instanceof ApiError && err.status === 401))
toast.error(err instanceof Error ? err.message : 'Export failed') toast.error(err instanceof Error ? err.message : 'Export failed')
@@ -166,9 +166,9 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
<Dialog open={open} onOpenChange={openChange}> <Dialog open={open} onOpenChange={openChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Import / Export devices</DialogTitle> <DialogTitle>Import / Export clients</DialogTitle>
<DialogDescription> <DialogDescription>
Bulk-add devices from a CSV, or download all devices as a CSV. Bulk-add clients from a CSV, or download all clients as a CSV.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -194,7 +194,7 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => downloadCsv('devices-template.csv', EXAMPLE_CSV)} onClick={() => downloadCsv('clients-template.csv', EXAMPLE_CSV)}
> >
<FileText className="h-4 w-4" /> Download <FileText className="h-4 w-4" /> Download
</Button> </Button>
@@ -231,8 +231,8 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
<section className="rounded-lg border p-3"> <section className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-sm"> <div className="text-sm">
<div className="font-medium">Export all devices</div> <div className="font-medium">Export all clients</div>
<div className="text-muted-foreground">Download every device as a CSV file.</div> <div className="text-muted-foreground">Download every client as a CSV file.</div>
</div> </div>
<Button variant="outline" size="sm" onClick={exportAll} disabled={exporting}> <Button variant="outline" size="sm" onClick={exportAll} disabled={exporting}>
{exporting ? ( {exporting ? (
@@ -258,7 +258,7 @@ function ImportPreview({
onConfirm, onConfirm,
onBack, onBack,
}: { }: {
preview: DeviceImportResult preview: ClientImportResult
busy: boolean busy: boolean
onConfirm: () => void onConfirm: () => void
onBack: () => void onBack: () => void
@@ -310,7 +310,7 @@ function ImportPreview({
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{hasErrors {hasErrors
? `Add the ${preview.valid} valid row${preview.valid === 1 ? '' : 's'} anyway, or go back and fix the file.` ? `Add the ${preview.valid} valid row${preview.valid === 1 ? '' : 's'} anyway, or go back and fix the file.`
: `All rows are valid. Import ${preview.valid} device${preview.valid === 1 ? '' : 's'}?`} : `All rows are valid. Import ${preview.valid} client${preview.valid === 1 ? '' : 's'}?`}
</p> </p>
)} )}
+39 -20
View File
@@ -80,18 +80,18 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
} }
// ---- Types ---- // ---- Types ----
export type DeviceStatus = 'new' | 'paid' | 'unpaid' export type ClientStatus = 'new' | 'paid' | 'unpaid'
export interface Device { export interface Client {
mac_address: string mac_address: string
group: string | null group: string | null
status: DeviceStatus | null status: ClientStatus | null
name: string | null name: string | null
phone: string | null phone: string | null
alias: string | null alias: string | null
} }
export interface DeviceCreate { export interface ClientCreate {
mac_address: string mac_address: string
group: string group: string
name: string name: string
@@ -99,16 +99,16 @@ export interface DeviceCreate {
alias?: string | null alias?: string | null
} }
export interface DeviceEdit { export interface ClientEdit {
mac_address: string mac_address: string
group?: string group?: string
status?: DeviceStatus status?: ClientStatus
name?: string name?: string
phone?: string phone?: string
alias?: string alias?: string
} }
export interface DeviceImportRow { export interface ClientImportRow {
mac_address?: string mac_address?: string
group?: string group?: string
name?: string name?: string
@@ -116,18 +116,26 @@ export interface DeviceImportRow {
alias?: string alias?: string
} }
export interface DeviceImportRowError { export interface ClientImportRowError {
row: number row: number
mac: string | null mac: string | null
detail: string detail: string
} }
export interface DeviceImportResult { export interface ClientImportResult {
total: number total: number
valid: number valid: number
created: number created: number
dry_run: boolean dry_run: boolean
errors: DeviceImportRowError[] errors: ClientImportRowError[]
}
// A NAS/AP device seen in radacct (keyed by AP MAC), with an editable alias.
export interface Device {
ap_mac: string
nasipaddress: string | null
ssids: string[]
alias: string | null
} }
export interface Vlan { export interface Vlan {
@@ -147,25 +155,36 @@ export const api = {
// health check to validate the API key on login // health check to validate the API key on login
ping: () => request<unknown>('/vlan/'), ping: () => request<unknown>('/vlan/'),
devices: { clients: {
list: (limit = 50, offset = 0) => list: (limit = 50, offset = 0) =>
request<Page<Device>>(`/device/?limit=${limit}&offset=${offset}`), request<Page<Client>>(`/client/?limit=${limit}&offset=${offset}`),
get: (mac: string) => request<Device>(`/device/${encodeURIComponent(mac)}`), get: (mac: string) => request<Client>(`/client/${encodeURIComponent(mac)}`),
add: (body: DeviceCreate) => add: (body: ClientCreate) =>
request<Device>('/device/add', { method: 'POST', body: JSON.stringify(body) }), request<Client>('/client/add', { method: 'POST', body: JSON.stringify(body) }),
edit: (body: DeviceEdit) => edit: (body: ClientEdit) =>
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }), request<Client>('/client/edit', { method: 'POST', body: JSON.stringify(body) }),
remove: (mac: string) => remove: (mac: string) =>
request<void>(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }), request<void>(`/client/${encodeURIComponent(mac)}`, { method: 'DELETE' }),
// Bulk import. dryRun=true validates only (nothing written) so the UI can // Bulk import. dryRun=true validates only (nothing written) so the UI can
// preview errors and confirm; dryRun=false inserts the valid rows. // preview errors and confirm; dryRun=false inserts the valid rows.
import: (devices: DeviceImportRow[], dryRun: boolean) => import: (devices: ClientImportRow[], dryRun: boolean) =>
request<DeviceImportResult>('/device/import', { request<ClientImportResult>('/client/import', {
method: 'POST', method: 'POST',
body: JSON.stringify({ devices, dry_run: dryRun }), body: JSON.stringify({ devices, dry_run: dryRun }),
}), }),
}, },
// NAS/AP boxes seen in radacct, keyed by AP MAC, with an editable human alias
// stored in radadmin_devices.
devices: {
list: () => request<Device[]>('/device/'),
setAlias: (apMac: string, alias: string | null) =>
request<Device>(`/device/${encodeURIComponent(apMac)}`, {
method: 'PUT',
body: JSON.stringify({ alias }),
}),
},
vlans: { vlans: {
list: () => request<Vlan[]>('/vlan/'), list: () => request<Vlan[]>('/vlan/'),
add: (vlanid: number, alias: string) => add: (vlanid: number, alias: string) =>
+474
View File
@@ -0,0 +1,474 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { api, ApiError, type Client, type ClientStatus, type Vlan } from '@/lib/api'
import { normalizePhone, phoneError } from '@/lib/phone'
import { Button } from '@/components/ui/button'
import { ClientImportExport } from '@/components/ClientImportExport'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const STATUSES: ClientStatus[] = ['new', 'paid', 'unpaid']
function StatusBadge({ status }: { status: ClientStatus | null }) {
if (!status) return <span className="text-muted-foreground"></span>
const variant = status === 'paid' ? 'success' : status === 'unpaid' ? 'destructive' : 'secondary'
return <Badge variant={variant}>{status}</Badge>
}
export function Clients() {
const [devices, setDevices] = useState<Client[]>([])
const [vlans, setVlans] = useState<Vlan[]>([])
const [loading, setLoading] = useState(true)
const [addOpen, setAddOpen] = useState(false)
const [editing, setEditing] = useState<Client | null>(null)
const [deleting, setDeleting] = useState<Client | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const [d, v] = await Promise.all([api.clients.list(200), api.vlans.list()])
setDevices(d.items)
setVlans(v)
} catch (err) {
if (!(err instanceof ApiError && err.status === 401))
toast.error(err instanceof Error ? err.message : 'Failed to load devices')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Clients</h1>
<p className="text-sm text-muted-foreground">{devices.length} registered</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
<ClientImportExport onImported={load} />
<Button onClick={() => setAddOpen(true)}>
<Plus /> Add device
</Button>
</div>
</div>
<div className="rounded-lg border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>MAC address</TableHead>
<TableHead>Name</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Group</TableHead>
<TableHead>Status</TableHead>
<TableHead>Alias</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && devices.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : devices.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
No devices yet. Add one to get started.
</TableCell>
</TableRow>
) : (
devices.map((d) => (
<TableRow key={d.mac_address}>
<TableCell className="font-mono text-xs">{d.mac_address}</TableCell>
<TableCell>{d.name ?? '—'}</TableCell>
<TableCell>{d.phone ?? '—'}</TableCell>
<TableCell>{d.group ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableCell>
<StatusBadge status={d.status} />
</TableCell>
<TableCell className="text-muted-foreground">{d.alias ?? '—'}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(d)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AddClientDialog
open={addOpen}
onOpenChange={setAddOpen}
vlans={vlans}
onSaved={load}
/>
<EditClientDialog
device={editing}
onClose={() => setEditing(null)}
vlans={vlans}
onSaved={load}
/>
<DeleteClientDialog device={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
</div>
)
}
// ---------- Add ----------
function AddClientDialog({
open,
onOpenChange,
vlans,
onSaved,
}: {
open: boolean
onOpenChange: (o: boolean) => void
vlans: Vlan[]
onSaved: () => void
}) {
const [mac, setMac] = useState('')
const [group, setGroup] = useState('')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [alias, setAlias] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (open) {
setMac('')
setGroup('')
setName('')
setPhone('')
setAlias('')
}
}, [open])
const phoneErr = phone.trim() ? phoneError(phone) : null
const valid = mac.trim() && group && name.trim() && phone.trim() && !phoneErr
async function submit() {
if (!valid) return
setBusy(true)
try {
await api.clients.add({
mac_address: mac.trim(),
group,
name: name.trim(),
phone: normalizePhone(phone) ?? phone.trim(),
alias: alias.trim() || null,
})
toast.success(`Client ${mac.trim()} added`)
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add device</DialogTitle>
<DialogDescription>Register a new MAC address and assign it to a VLAN group.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field label="MAC address" required>
<Input
placeholder="AA-BB-CC-DD-EE-FF"
className="font-mono"
value={mac}
onChange={(e) => setMac(e.target.value)}
/>
</Field>
<Field label="Group (VLAN)" required>
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Name" required>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Phone" required error={phoneErr}>
<Input
placeholder="9XXXXXX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Field>
</div>
<Field label="Device alias (optional)">
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={submit} disabled={busy || !valid}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Add device
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- Edit ----------
function EditClientDialog({
device,
onClose,
vlans,
onSaved,
}: {
device: Client | null
onClose: () => void
vlans: Vlan[]
onSaved: () => void
}) {
const [group, setGroup] = useState('')
const [status, setStatus] = useState<ClientStatus>('new')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [alias, setAlias] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (device) {
setGroup(device.group ?? '')
setStatus(device.status ?? 'new')
setName(device.name ?? '')
setPhone(device.phone ?? '')
setAlias(device.alias ?? '')
}
}, [device])
const phoneErr = phone.trim() ? phoneError(phone) : 'Phone is required'
async function submit() {
if (!device || phoneErr) return
setBusy(true)
try {
await api.clients.edit({
mac_address: device.mac_address,
group: group || undefined,
status,
name,
phone: normalizePhone(phone) ?? phone,
alias,
})
toast.success(`Client ${device.mac_address} updated`)
onClose()
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit device</DialogTitle>
<DialogDescription className="font-mono">{device?.mac_address}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Group (VLAN)">
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
</Field>
<Field label="Status">
<Select value={status} onValueChange={(v) => setStatus(v as ClientStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Name">
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Phone" error={phoneErr}>
<Input
placeholder="9XXXXXX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Field>
</div>
<Field label="Device alias">
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={busy || !!phoneErr}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- Delete ----------
function DeleteClientDialog({
device,
onClose,
onDeleted,
}: {
device: Client | null
onClose: () => void
onDeleted: () => void
}) {
const [busy, setBusy] = useState(false)
async function confirm() {
if (!device) return
setBusy(true)
try {
await api.clients.remove(device.mac_address)
toast.success(`Client ${device.mac_address} deleted`)
onClose()
onDeleted()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete device?</DialogTitle>
<DialogDescription>
This removes <span className="font-mono">{device?.mac_address}</span> from radcheck,
radusergroup and customers. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button variant="destructive" onClick={confirm} disabled={busy}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- shared bits ----------
function Field({
label,
required,
error,
children,
}: {
label: string
required?: boolean
error?: string | null
children: ReactNode
}) {
return (
<div className="space-y-1.5">
<Label>
{label}
{required && <span className="text-destructive"> *</span>}
</Label>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
)
}
function GroupSelect({
vlans,
value,
onChange,
}: {
vlans: Vlan[]
value: string
onChange: (v: string) => void
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a VLAN" />
</SelectTrigger>
<SelectContent>
{vlans.map((v) => (
<SelectItem key={v.vlanid} value={v.alias}>
{v.alias} (VLAN {v.vlanid})
</SelectItem>
))}
</SelectContent>
</Select>
)
}
+63 -349
View File
@@ -1,11 +1,8 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react' import { Loader2, Pencil, RefreshCw, Wifi } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api' import { api, ApiError, type Device } from '@/lib/api'
import { normalizePhone, phoneError } from '@/lib/phone'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DeviceImportExport } from '@/components/DeviceImportExport'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
@@ -24,36 +21,30 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const STATUSES: DeviceStatus[] = ['new', 'paid', 'unpaid'] function Ssids({ ssids }: { ssids: string[] }) {
if (ssids.length === 0) return <span className="text-muted-foreground"></span>
function StatusBadge({ status }: { status: DeviceStatus | null }) { const extra = ssids.length - 1
if (!status) return <span className="text-muted-foreground"></span> return (
const variant = status === 'paid' ? 'success' : status === 'unpaid' ? 'destructive' : 'secondary' <span className="inline-flex items-center gap-1.5" title={ssids.join(', ')}>
return <Badge variant={variant}>{status}</Badge> <Wifi className="h-3.5 w-3.5 text-muted-foreground" />
{ssids[0]}
{extra > 0 && (
<span className="text-muted-foreground">(and {extra} more)</span>
)}
</span>
)
} }
export function Devices() { export function Devices() {
const [devices, setDevices] = useState<Device[]>([]) const [devices, setDevices] = useState<Device[]>([])
const [vlans, setVlans] = useState<Vlan[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [addOpen, setAddOpen] = useState(false)
const [editing, setEditing] = useState<Device | null>(null) const [editing, setEditing] = useState<Device | null>(null)
const [deleting, setDeleting] = useState<Device | null>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const [d, v] = await Promise.all([api.devices.list(200), api.vlans.list()]) setDevices(await api.devices.list())
setDevices(d.items)
setVlans(v)
} catch (err) { } catch (err) {
if (!(err instanceof ApiError && err.status === 401)) if (!(err instanceof ApiError && err.status === 401))
toast.error(err instanceof Error ? err.message : 'Failed to load devices') toast.error(err instanceof Error ? err.message : 'Failed to load devices')
@@ -71,71 +62,54 @@ export function Devices() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold">Devices</h1> <h1 className="text-2xl font-semibold">Devices</h1>
<p className="text-sm text-muted-foreground">{devices.length} registered</p> <p className="text-sm text-muted-foreground">
</div> {devices.length} NAS/AP {devices.length === 1 ? 'device' : 'devices'} seen in accounting
<div className="flex gap-2"> </p>
<Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
<DeviceImportExport onImported={load} />
<Button onClick={() => setAddOpen(true)}>
<Plus /> Add device
</Button>
</div> </div>
<Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
</div> </div>
<div className="rounded-lg border bg-background"> <div className="rounded-lg border bg-background">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>MAC address</TableHead> <TableHead>AP MAC</TableHead>
<TableHead>Name</TableHead> <TableHead>IP</TableHead>
<TableHead>Phone</TableHead> <TableHead>SSIDs</TableHead>
<TableHead>Group</TableHead>
<TableHead>Status</TableHead>
<TableHead>Alias</TableHead> <TableHead>Alias</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead> <TableHead className="w-16 text-right">Edit</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading && devices.length === 0 ? ( {loading && devices.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground"> <TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" /> <Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : devices.length === 0 ? ( ) : devices.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground"> <TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
No devices yet. Add one to get started. No devices have reported accounting yet.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
devices.map((d) => ( devices.map((d) => (
<TableRow key={d.mac_address}> <TableRow key={d.ap_mac}>
<TableCell className="font-mono text-xs">{d.mac_address}</TableCell> <TableCell className="font-mono text-xs">{d.ap_mac}</TableCell>
<TableCell>{d.name ?? '—'}</TableCell> <TableCell className="font-mono text-xs">{d.nasipaddress ?? '—'}</TableCell>
<TableCell>{d.phone ?? '—'}</TableCell>
<TableCell>{d.group ?? <span className="text-muted-foreground"></span>}</TableCell>
<TableCell> <TableCell>
<StatusBadge status={d.status} /> <Ssids ssids={d.ssids} />
</TableCell>
<TableCell>
{d.alias ?? <span className="text-muted-foreground"></span>}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{d.alias ?? '—'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-1"> <Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit alias">
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit"> <Pencil className="h-4 w-4" />
<Pencil className="h-4 w-4" /> </Button>
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(d)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -144,257 +118,38 @@ export function Devices() {
</Table> </Table>
</div> </div>
<AddDeviceDialog <EditAliasDialog device={editing} onClose={() => setEditing(null)} onSaved={load} />
open={addOpen}
onOpenChange={setAddOpen}
vlans={vlans}
onSaved={load}
/>
<EditDeviceDialog
device={editing}
onClose={() => setEditing(null)}
vlans={vlans}
onSaved={load}
/>
<DeleteDeviceDialog device={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
</div> </div>
) )
} }
// ---------- Add ---------- function EditAliasDialog({
function AddDeviceDialog({
open,
onOpenChange,
vlans,
onSaved,
}: {
open: boolean
onOpenChange: (o: boolean) => void
vlans: Vlan[]
onSaved: () => void
}) {
const [mac, setMac] = useState('')
const [group, setGroup] = useState('')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [alias, setAlias] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (open) {
setMac('')
setGroup('')
setName('')
setPhone('')
setAlias('')
}
}, [open])
const phoneErr = phone.trim() ? phoneError(phone) : null
const valid = mac.trim() && group && name.trim() && phone.trim() && !phoneErr
async function submit() {
if (!valid) return
setBusy(true)
try {
await api.devices.add({
mac_address: mac.trim(),
group,
name: name.trim(),
phone: normalizePhone(phone) ?? phone.trim(),
alias: alias.trim() || null,
})
toast.success(`Device ${mac.trim()} added`)
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add device</DialogTitle>
<DialogDescription>Register a new MAC address and assign it to a VLAN group.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field label="MAC address" required>
<Input
placeholder="AA-BB-CC-DD-EE-FF"
className="font-mono"
value={mac}
onChange={(e) => setMac(e.target.value)}
/>
</Field>
<Field label="Group (VLAN)" required>
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Name" required>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Phone" required error={phoneErr}>
<Input
placeholder="9XXXXXX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Field>
</div>
<Field label="Device alias (optional)">
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={submit} disabled={busy || !valid}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Add device
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- Edit ----------
function EditDeviceDialog({
device, device,
onClose, onClose,
vlans,
onSaved, onSaved,
}: { }: {
device: Device | null device: Device | null
onClose: () => void onClose: () => void
vlans: Vlan[]
onSaved: () => void onSaved: () => void
}) { }) {
const [group, setGroup] = useState('')
const [status, setStatus] = useState<DeviceStatus>('new')
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [alias, setAlias] = useState('') const [alias, setAlias] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
useEffect(() => { useEffect(() => {
if (device) { if (device) setAlias(device.alias ?? '')
setGroup(device.group ?? '')
setStatus(device.status ?? 'new')
setName(device.name ?? '')
setPhone(device.phone ?? '')
setAlias(device.alias ?? '')
}
}, [device]) }, [device])
const phoneErr = phone.trim() ? phoneError(phone) : 'Phone is required'
async function submit() { async function submit() {
if (!device || phoneErr) return
setBusy(true)
try {
await api.devices.edit({
mac_address: device.mac_address,
group: group || undefined,
status,
name,
phone: normalizePhone(phone) ?? phone,
alias,
})
toast.success(`Device ${device.mac_address} updated`)
onClose()
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update device')
} finally {
setBusy(false)
}
}
return (
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit device</DialogTitle>
<DialogDescription className="font-mono">{device?.mac_address}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Group (VLAN)">
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
</Field>
<Field label="Status">
<Select value={status} onValueChange={(v) => setStatus(v as DeviceStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Name">
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Phone" error={phoneErr}>
<Input
placeholder="9XXXXXX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Field>
</div>
<Field label="Device alias">
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={busy || !!phoneErr}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------- Delete ----------
function DeleteDeviceDialog({
device,
onClose,
onDeleted,
}: {
device: Device | null
onClose: () => void
onDeleted: () => void
}) {
const [busy, setBusy] = useState(false)
async function confirm() {
if (!device) return if (!device) return
setBusy(true) setBusy(true)
try { try {
await api.devices.remove(device.mac_address) const value = alias.trim() || null
toast.success(`Device ${device.mac_address} deleted`) await api.devices.setAlias(device.ap_mac, value)
toast.success(`Alias ${value ? 'saved' : 'cleared'} for ${device.ap_mac}`)
onClose() onClose()
onDeleted() onSaved()
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete device') toast.error(err instanceof Error ? err.message : 'Failed to save alias')
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -404,71 +159,30 @@ function DeleteDeviceDialog({
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Delete device?</DialogTitle> <DialogTitle>Edit device alias</DialogTitle>
<DialogDescription> <DialogDescription className="font-mono">{device?.ap_mac}</DialogDescription>
This removes <span className="font-mono">{device?.mac_address}</span> from radcheck,
radusergroup and customers. This cannot be undone.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-1.5">
<Label>Alias</Label>
<Input
placeholder="e.g. Lobby AP"
value={alias}
onChange={(e) => setAlias(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
autoFocus
/>
<p className="text-xs text-muted-foreground">Leave empty to clear the alias.</p>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose} disabled={busy}>
Cancel Cancel
</Button> </Button>
<Button variant="destructive" onClick={confirm} disabled={busy}> <Button onClick={submit} disabled={busy}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />} {busy && <Loader2 className="h-4 w-4 animate-spin" />}
Delete Save
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
} }
// ---------- shared bits ----------
function Field({
label,
required,
error,
children,
}: {
label: string
required?: boolean
error?: string | null
children: ReactNode
}) {
return (
<div className="space-y-1.5">
<Label>
{label}
{required && <span className="text-destructive"> *</span>}
</Label>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
)
}
function GroupSelect({
vlans,
value,
onChange,
}: {
vlans: Vlan[]
value: string
onChange: (v: string) => void
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a VLAN" />
</SelectTrigger>
<SelectContent>
{vlans.map((v) => (
<SelectItem key={v.vlanid} value={v.alias}>
{v.alias} (VLAN {v.vlanid})
</SelectItem>
))}
</SelectContent>
</Select>
)
}