This commit is contained in:
@@ -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
@@ -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, ...
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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`).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+21
-21
@@ -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
@@ -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) =>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user