Add roster management interface and related API endpoints

- Implemented a new `/roster` endpoint to retrieve and manage device configurations.
- Added HTML template for the roster page with a table to display device status and actions.
- Introduced functionality to add, edit, and delete devices via the roster interface.
- Enhanced `ConfigPayload` model to include polling options.
- Updated the main application to serve the new roster page and link to it from the index.
- Added validation for polling interval in the configuration payload.
- Created detailed documentation for the roster management features and API endpoints.
This commit is contained in:
serversdwn
2026-01-17 08:00:05 +00:00
parent 182920809d
commit 82651f71b5
5 changed files with 1175 additions and 10 deletions

View File

@@ -72,6 +72,11 @@ def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/roster", response_class=HTMLResponse)
def roster(request: Request):
return templates.TemplateResponse("roster.html", {"request": request})
@app.get("/health")
async def health():
"""Basic health check endpoint."""

View File

@@ -49,6 +49,8 @@ class ConfigPayload(BaseModel):
ftp_username: str | None = None
ftp_password: str | None = None
web_enabled: bool | None = None
poll_enabled: bool | None = None
poll_interval_seconds: int | None = None
@field_validator("host")
@classmethod
@@ -76,6 +78,13 @@ class ConfigPayload(BaseModel):
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("poll_interval_seconds")
@classmethod
def validate_poll_interval(cls, v):
if v is not None and not (10 <= v <= 3600):
raise ValueError("Poll interval must be between 10 and 3600 seconds")
return v
class PollingConfigPayload(BaseModel):
"""Payload for updating device polling configuration."""
@@ -131,6 +140,164 @@ def get_global_polling_status(db: Session = Depends(get_db)):
}
@router.get("/roster")
def get_roster(db: Session = Depends(get_db)):
"""
Get list of all configured devices with their status.
Returns all NL43Config entries along with their associated status information.
Used by the roster page to display all devices in a table.
Note: Must be defined before /{unit_id} routes to avoid routing conflicts.
"""
configs = db.query(NL43Config).all()
devices = []
for cfg in configs:
status = db.query(NL43Status).filter_by(unit_id=cfg.unit_id).first()
device_data = {
"unit_id": cfg.unit_id,
"host": cfg.host,
"tcp_port": cfg.tcp_port,
"ftp_port": cfg.ftp_port,
"tcp_enabled": cfg.tcp_enabled,
"ftp_enabled": cfg.ftp_enabled,
"ftp_username": cfg.ftp_username,
"ftp_password": cfg.ftp_password,
"web_enabled": cfg.web_enabled,
"poll_enabled": cfg.poll_enabled,
"poll_interval_seconds": cfg.poll_interval_seconds,
"status": None
}
if status:
device_data["status"] = {
"last_seen": status.last_seen.isoformat() if status.last_seen else None,
"measurement_state": status.measurement_state,
"is_reachable": status.is_reachable,
"consecutive_failures": status.consecutive_failures,
"last_success": status.last_success.isoformat() if status.last_success else None,
"last_error": status.last_error
}
devices.append(device_data)
return {
"status": "ok",
"devices": devices,
"total": len(devices)
}
class RosterCreatePayload(BaseModel):
"""Payload for creating a new device via roster."""
unit_id: str
host: str
tcp_port: int = 2255
ftp_port: int = 21
tcp_enabled: bool = True
ftp_enabled: bool = False
ftp_username: str | None = None
ftp_password: str | None = None
web_enabled: bool = False
poll_enabled: bool = True
poll_interval_seconds: int = 60
@field_validator("host")
@classmethod
def validate_host(cls, v):
if v is None:
return v
# Try to parse as IP address or hostname
try:
ipaddress.ip_address(v)
except ValueError:
# Not an IP, check if it's a valid hostname format
if not v or len(v) > 253:
raise ValueError("Invalid hostname length")
# Allow hostnames (basic validation)
if not all(c.isalnum() or c in ".-" for c in v):
raise ValueError("Host must be a valid IP address or hostname")
return v
@field_validator("tcp_port", "ftp_port")
@classmethod
def validate_port(cls, v):
if v is None:
return v
if not (1 <= v <= 65535):
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("poll_interval_seconds")
@classmethod
def validate_poll_interval(cls, v):
if v is not None and not (10 <= v <= 3600):
raise ValueError("Poll interval must be between 10 and 3600 seconds")
return v
@router.post("/roster")
async def create_device(payload: RosterCreatePayload, db: Session = Depends(get_db)):
"""
Create a new device configuration via roster.
This endpoint allows creating a new device with all configuration options.
If a device with the same unit_id already exists, returns a 409 conflict.
Note: Must be defined before /{unit_id} routes to avoid routing conflicts.
"""
# Check if device already exists
existing = db.query(NL43Config).filter_by(unit_id=payload.unit_id).first()
if existing:
raise HTTPException(
status_code=409,
detail=f"Device with unit_id '{payload.unit_id}' already exists. Use PUT /{payload.unit_id}/config to update."
)
# Create new config
cfg = NL43Config(
unit_id=payload.unit_id,
host=payload.host,
tcp_port=payload.tcp_port,
ftp_port=payload.ftp_port,
tcp_enabled=payload.tcp_enabled,
ftp_enabled=payload.ftp_enabled,
ftp_username=payload.ftp_username,
ftp_password=payload.ftp_password,
web_enabled=payload.web_enabled,
poll_enabled=payload.poll_enabled,
poll_interval_seconds=payload.poll_interval_seconds
)
db.add(cfg)
db.commit()
db.refresh(cfg)
logger.info(f"Created new device config for {payload.unit_id}")
# If TCP is enabled, automatically disable sleep mode
if cfg.tcp_enabled and cfg.host and cfg.tcp_port:
logger.info(f"TCP enabled for {payload.unit_id}, ensuring sleep mode is disabled")
client = NL43Client(cfg.host, cfg.tcp_port, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password, ftp_port=cfg.ftp_port)
await ensure_sleep_mode_disabled(client, payload.unit_id)
return {
"status": "ok",
"message": f"Device {payload.unit_id} created successfully",
"data": {
"unit_id": cfg.unit_id,
"host": cfg.host,
"tcp_port": cfg.tcp_port,
"tcp_enabled": cfg.tcp_enabled,
"ftp_enabled": cfg.ftp_enabled,
"poll_enabled": cfg.poll_enabled,
"poll_interval_seconds": cfg.poll_interval_seconds
}
}
# ============================================================================
# DEVICE-SPECIFIC ENDPOINTS
# ============================================================================
@@ -207,6 +374,10 @@ async def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depe
cfg.ftp_password = payload.ftp_password
if payload.web_enabled is not None:
cfg.web_enabled = payload.web_enabled
if payload.poll_enabled is not None:
cfg.poll_enabled = payload.poll_enabled
if payload.poll_interval_seconds is not None:
cfg.poll_interval_seconds = payload.poll_interval_seconds
db.commit()
db.refresh(cfg)
@@ -228,6 +399,8 @@ async def upsert_config(unit_id: str, payload: ConfigPayload, db: Session = Depe
"tcp_enabled": cfg.tcp_enabled,
"ftp_enabled": cfg.ftp_enabled,
"web_enabled": cfg.web_enabled,
"poll_enabled": cfg.poll_enabled,
"poll_interval_seconds": cfg.poll_interval_seconds,
},
}

246
docs/ROSTER.md Normal file
View File

@@ -0,0 +1,246 @@
# SLMM Roster Management
The SLMM standalone application now includes a roster management interface for viewing and configuring all Sound Level Meter devices.
## Features
### Web Interface
Access the roster at: **http://localhost:8100/roster**
The roster page provides:
- **Device List Table**: View all configured SLMs with their connection details
- **Real-time Status**: See device connectivity status (Online/Offline/Stale)
- **Add Device**: Create new device configurations with a user-friendly modal form
- **Edit Device**: Modify existing device configurations
- **Delete Device**: Remove device configurations (does not affect physical devices)
- **Test Connection**: Run diagnostics on individual devices
### Table Columns
| Column | Description |
|--------|-------------|
| Unit ID | Unique identifier for the device |
| Host / IP | Device IP address or hostname |
| TCP Port | TCP control port (default: 2255) |
| FTP Port | FTP file transfer port (default: 21) |
| TCP | Whether TCP control is enabled |
| FTP | Whether FTP file transfer is enabled |
| Polling | Whether background polling is enabled |
| Status | Device connectivity status (Online/Offline/Stale) |
| Actions | Test, Edit, Delete buttons |
### Status Indicators
- **Online** (green): Device responded within the last 5 minutes
- **Stale** (yellow): Device hasn't responded recently but was seen before
- **Offline** (red): Device is unreachable or has consecutive failures
- **Unknown** (gray): No status data available yet
## API Endpoints
### List All Devices
```bash
GET /api/nl43/roster
```
Returns all configured devices with their status information.
**Response:**
```json
{
"status": "ok",
"devices": [
{
"unit_id": "SLM-43-01",
"host": "192.168.1.100",
"tcp_port": 2255,
"ftp_port": 21,
"tcp_enabled": true,
"ftp_enabled": true,
"ftp_username": "USER",
"ftp_password": "0000",
"web_enabled": false,
"poll_enabled": true,
"poll_interval_seconds": 60,
"status": {
"last_seen": "2026-01-16T20:00:00",
"measurement_state": "Start",
"is_reachable": true,
"consecutive_failures": 0,
"last_success": "2026-01-16T20:00:00",
"last_error": null
}
}
],
"total": 1
}
```
### Create New Device
```bash
POST /api/nl43/roster
Content-Type: application/json
{
"unit_id": "SLM-43-01",
"host": "192.168.1.100",
"tcp_port": 2255,
"ftp_port": 21,
"tcp_enabled": true,
"ftp_enabled": false,
"poll_enabled": true,
"poll_interval_seconds": 60
}
```
**Required Fields:**
- `unit_id`: Unique device identifier
- `host`: IP address or hostname
**Optional Fields:**
- `tcp_port`: TCP control port (default: 2255)
- `ftp_port`: FTP port (default: 21)
- `tcp_enabled`: Enable TCP control (default: true)
- `ftp_enabled`: Enable FTP transfers (default: false)
- `ftp_username`: FTP username (only if ftp_enabled)
- `ftp_password`: FTP password (only if ftp_enabled)
- `poll_enabled`: Enable background polling (default: true)
- `poll_interval_seconds`: Polling interval 10-3600 seconds (default: 60)
**Response:**
```json
{
"status": "ok",
"message": "Device SLM-43-01 created successfully",
"data": {
"unit_id": "SLM-43-01",
"host": "192.168.1.100",
"tcp_port": 2255,
"tcp_enabled": true,
"ftp_enabled": false,
"poll_enabled": true,
"poll_interval_seconds": 60
}
}
```
### Update Device
```bash
PUT /api/nl43/{unit_id}/config
Content-Type: application/json
{
"host": "192.168.1.101",
"tcp_port": 2255,
"poll_interval_seconds": 120
}
```
All fields are optional. Only include fields you want to update.
### Delete Device
```bash
DELETE /api/nl43/{unit_id}/config
```
Removes the device configuration and associated status data. Does not affect the physical device.
**Response:**
```json
{
"status": "ok",
"message": "Deleted device SLM-43-01"
}
```
## Usage Examples
### Via Web Interface
1. Navigate to http://localhost:8100/roster
2. Click "Add Device" to create a new configuration
3. Fill in the device details (unit ID, IP address, ports)
4. Configure TCP, FTP, and polling settings
5. Click "Save Device"
6. Use "Test" button to verify connectivity
7. Edit or delete devices as needed
### Via API (curl)
**Add a new device:**
```bash
curl -X POST http://localhost:8100/api/nl43/roster \
-H "Content-Type: application/json" \
-d '{
"unit_id": "slm-site-a",
"host": "192.168.1.100",
"tcp_port": 2255,
"tcp_enabled": true,
"ftp_enabled": true,
"ftp_username": "USER",
"ftp_password": "0000",
"poll_enabled": true,
"poll_interval_seconds": 60
}'
```
**Update device host:**
```bash
curl -X PUT http://localhost:8100/api/nl43/slm-site-a/config \
-H "Content-Type: application/json" \
-d '{"host": "192.168.1.101"}'
```
**Delete device:**
```bash
curl -X DELETE http://localhost:8100/api/nl43/slm-site-a/config
```
**List all devices:**
```bash
curl http://localhost:8100/api/nl43/roster | python3 -m json.tool
```
## Integration with Terra-View
When SLMM is used as a module within Terra-View:
1. Terra-View manages device configurations in its own database
2. Terra-View syncs configurations to SLMM via `PUT /api/nl43/{unit_id}/config`
3. Terra-View can query device status via `GET /api/nl43/{unit_id}/status`
4. SLMM's roster page can be used for standalone testing and diagnostics
## Background Polling
Devices with `poll_enabled: true` are automatically polled at their configured interval:
- Polls device status every `poll_interval_seconds` (10-3600 seconds)
- Updates `NL43Status` table with latest measurements
- Tracks device reachability and failure counts
- Provides real-time status updates in the roster
**Note**: Polling respects the NL43 protocol's 1-second rate limit between commands.
## Validation
The roster system validates:
- **Unit ID**: Must be unique across all devices
- **Host**: Valid IP address or hostname format
- **Ports**: Must be between 1-65535
- **Poll Interval**: Must be between 10-3600 seconds
- **Duplicate Check**: Returns 409 Conflict if unit_id already exists
## Notes
- Deleting a device from the roster does NOT affect the physical device
- Device configurations are stored in the SLMM database (`data/slmm.db`)
- Status information is updated by the background polling system
- The roster page auto-refreshes status indicators
- Test button runs full diagnostics (connectivity, TCP, FTP if enabled)

View File

@@ -31,6 +31,11 @@
<body>
<h1>SLMM NL43 Standalone</h1>
<p>Configure a unit (host/port), then use controls to Start/Stop and fetch live status.</p>
<p style="margin-bottom: 16px;">
<a href="/roster" style="color: #0969da; text-decoration: none; font-weight: 600;">📊 View Device Roster</a>
<span style="margin: 0 8px; color: #d0d7de;">|</span>
<a href="/docs" style="color: #0969da; text-decoration: none;">API Documentation</a>
</p>
<fieldset>
<legend>🔍 Connection Diagnostics</legend>
@@ -40,13 +45,34 @@
</fieldset>
<fieldset>
<legend>Unit Config</legend>
<legend>Unit Selection & Config</legend>
<div style="display: flex; gap: 8px; align-items: flex-end; margin-bottom: 12px;">
<div style="flex: 1;">
<label>Select Device</label>
<select id="deviceSelector" onchange="loadSelectedDevice()" style="width: 100%; padding: 8px; margin-bottom: 0;">
<option value="">-- Select a device --</option>
</select>
</div>
<button onclick="refreshDeviceList()" style="padding: 8px 12px;">↻ Refresh</button>
</div>
<div style="padding: 12px; background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px; margin-bottom: 12px;">
<div style="display: flex; gap: 16px;">
<div style="flex: 1;">
<label>Unit ID</label>
<input id="unitId" value="nl43-1" />
</div>
<div style="flex: 2;">
<label>Host</label>
<input id="host" value="127.0.0.1" />
<label>Port</label>
<input id="port" type="number" value="80" />
</div>
<div style="flex: 1;">
<label>TCP Port</label>
<input id="port" type="number" value="2255" />
</div>
</div>
</div>
<div style="margin: 12px 0;">
<label style="display: inline-flex; align-items: center; margin-right: 16px;">
@@ -66,8 +92,10 @@
<input id="ftpPassword" type="password" value="0000" />
</div>
<button onclick="saveConfig()" style="margin-top: 12px;">Save Config</button>
<div style="margin-top: 12px;">
<button onclick="saveConfig()">Save Config</button>
<button onclick="loadConfig()">Load Config</button>
</div>
</fieldset>
<fieldset>
@@ -148,6 +176,7 @@
let ws = null;
let streamUpdateCount = 0;
let availableDevices = [];
function log(msg) {
logEl.textContent += msg + "\n";
@@ -160,9 +189,97 @@
ftpCredentials.style.display = ftpEnabled ? 'block' : 'none';
}
// Add event listener for FTP checkbox
// Load device list from roster
async function refreshDeviceList() {
try {
const res = await fetch('/api/nl43/roster');
const data = await res.json();
if (!res.ok) {
log('Failed to load device list');
return;
}
availableDevices = data.devices || [];
const selector = document.getElementById('deviceSelector');
// Save current selection
const currentSelection = selector.value;
// Clear and rebuild options
selector.innerHTML = '<option value="">-- Select a device --</option>';
availableDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.unit_id;
// Add status indicator
let statusIcon = '⚪';
if (device.status) {
if (device.status.is_reachable === false) {
statusIcon = '🔴';
} else if (device.status.last_success) {
const lastSeen = new Date(device.status.last_success);
const ageMinutes = Math.floor((Date.now() - lastSeen) / 60000);
statusIcon = ageMinutes < 5 ? '🟢' : '🟡';
}
}
option.textContent = `${statusIcon} ${device.unit_id} (${device.host})`;
selector.appendChild(option);
});
// Restore selection if it still exists
if (currentSelection && availableDevices.find(d => d.unit_id === currentSelection)) {
selector.value = currentSelection;
}
log(`Loaded ${availableDevices.length} device(s) from roster`);
} catch (err) {
log(`Error loading device list: ${err.message}`);
}
}
// Load selected device configuration
function loadSelectedDevice() {
const selector = document.getElementById('deviceSelector');
const unitId = selector.value;
if (!unitId) {
return;
}
const device = availableDevices.find(d => d.unit_id === unitId);
if (!device) {
log(`Device ${unitId} not found in list`);
return;
}
// Populate form fields
document.getElementById('unitId').value = device.unit_id;
document.getElementById('host').value = device.host;
document.getElementById('port').value = device.tcp_port || 2255;
document.getElementById('tcpEnabled').checked = device.tcp_enabled || false;
document.getElementById('ftpEnabled').checked = device.ftp_enabled || false;
if (device.ftp_username) {
document.getElementById('ftpUsername').value = device.ftp_username;
}
if (device.ftp_password) {
document.getElementById('ftpPassword').value = device.ftp_password;
}
toggleFtpCredentials();
log(`Loaded configuration for ${device.unit_id}`);
}
// Add event listeners
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('ftpEnabled').addEventListener('change', toggleFtpCredentials);
// Load device list on page load
refreshDeviceList();
});
async function runDiagnostics() {

624
templates/roster.html Normal file
View File

@@ -0,0 +1,624 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SLMM Roster - Sound Level Meter Configuration</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 24px;
background: #f6f8fa;
}
.container { max-width: 1400px; margin: 0 auto; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 16px;
background: white;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
h1 { margin: 0; font-size: 24px; }
.nav { display: flex; gap: 12px; }
.btn {
padding: 8px 16px;
border: 1px solid #d0d7de;
background: white;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
color: #24292f;
font-size: 14px;
transition: background 0.2s;
}
.btn:hover { background: #f6f8fa; }
.btn-primary {
background: #2da44e;
color: white;
border-color: #2da44e;
}
.btn-primary:hover { background: #2c974b; }
.btn-danger {
background: #cf222e;
color: white;
border-color: #cf222e;
}
.btn-danger:hover { background: #a40e26; }
.btn-small {
padding: 4px 8px;
font-size: 12px;
margin-right: 4px;
}
.table-container {
background: white;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background: #f6f8fa;
padding: 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #d0d7de;
font-size: 13px;
white-space: nowrap;
}
td {
padding: 12px;
border-bottom: 1px solid #d0d7de;
font-size: 13px;
}
tr:hover { background: #f6f8fa; }
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.status-ok {
background: #dafbe1;
color: #1a7f37;
}
.status-unknown {
background: #eaeef2;
color: #57606a;
}
.status-error {
background: #ffebe9;
color: #cf222e;
}
.checkbox-cell {
text-align: center;
width: 80px;
}
.checkbox-cell input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
}
.actions-cell {
white-space: nowrap;
width: 200px;
}
.empty-state {
text-align: center;
padding: 48px;
color: #57606a;
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active { display: flex; }
.modal-content {
background: white;
padding: 24px;
border-radius: 6px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-header h2 {
margin: 0;
font-size: 20px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #57606a;
padding: 0;
width: 32px;
height: 32px;
}
.close-btn:hover { color: #24292f; }
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 14px;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="password"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #d0d7de;
border-radius: 6px;
font-size: 14px;
}
.form-group input[type="checkbox"] {
width: auto;
margin-right: 8px;
}
.checkbox-label {
display: flex;
align-items: center;
font-weight: normal;
cursor: pointer;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.toast {
position: fixed;
top: 24px;
right: 24px;
padding: 12px 16px;
background: #24292f;
color: white;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 2000;
display: none;
min-width: 300px;
}
.toast.active {
display: block;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast-success { background: #2da44e; }
.toast-error { background: #cf222e; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 Sound Level Meter Roster</h1>
<div class="nav">
<a href="/" class="btn">← Back to Control Panel</a>
<button class="btn btn-primary" onclick="openAddModal()">+ Add Device</button>
</div>
</div>
<div class="table-container">
<table id="rosterTable">
<thead>
<tr>
<th>Unit ID</th>
<th>Host / IP</th>
<th>TCP Port</th>
<th>FTP Port</th>
<th class="checkbox-cell">TCP</th>
<th class="checkbox-cell">FTP</th>
<th class="checkbox-cell">Polling</th>
<th>Status</th>
<th class="actions-cell">Actions</th>
</tr>
</thead>
<tbody id="rosterBody">
<tr>
<td colspan="9" style="text-align: center; padding: 24px;">
Loading...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Add/Edit Modal -->
<div id="deviceModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Add Device</h2>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<form id="deviceForm" onsubmit="saveDevice(event)">
<div class="form-group">
<label for="unitId">Unit ID *</label>
<input type="text" id="unitId" required placeholder="e.g., nl43-1, slm-site-a" />
</div>
<div class="form-group">
<label for="host">Host / IP Address *</label>
<input type="text" id="host" required placeholder="e.g., 192.168.1.100" />
</div>
<div class="form-group">
<label for="tcpPort">TCP Port *</label>
<input type="number" id="tcpPort" required value="2255" min="1" max="65535" />
</div>
<div class="form-group">
<label for="ftpPort">FTP Port</label>
<input type="number" id="ftpPort" value="21" min="1" max="65535" />
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="tcpEnabled" checked />
TCP Enabled (required for remote control)
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="ftpEnabled" onchange="toggleFtpCredentials()" />
FTP Enabled (for file downloads)
</label>
</div>
<div id="ftpCredentialsSection" style="display: none; padding: 12px; background: #f6f8fa; border-radius: 6px; margin-bottom: 16px;">
<div class="form-group">
<label for="ftpUsername">FTP Username</label>
<input type="text" id="ftpUsername" placeholder="Default: USER" />
</div>
<div class="form-group">
<label for="ftpPassword">FTP Password</label>
<input type="password" id="ftpPassword" placeholder="Default: 0000" />
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="pollEnabled" checked />
Enable background polling (status updates)
</label>
</div>
<div class="form-group">
<label for="pollInterval">Polling Interval (seconds)</label>
<input type="number" id="pollInterval" value="60" min="10" max="3600" />
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Device</button>
</div>
</form>
</div>
</div>
<!-- Toast Notification -->
<div id="toast" class="toast"></div>
<script>
let devices = [];
let editingDeviceId = null;
// Load roster on page load
document.addEventListener('DOMContentLoaded', () => {
loadRoster();
});
async function loadRoster() {
try {
const res = await fetch('/api/nl43/roster');
const data = await res.json();
if (!res.ok) {
showToast('Failed to load roster', 'error');
return;
}
devices = data.devices || [];
renderRoster();
} catch (err) {
showToast('Error loading roster: ' + err.message, 'error');
console.error('Load roster error:', err);
}
}
function renderRoster() {
const tbody = document.getElementById('rosterBody');
if (devices.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="9" class="empty-state">
<div class="empty-state-icon">📭</div>
<div><strong>No devices configured</strong></div>
<div style="margin-top: 8px; font-size: 14px;">Click "Add Device" to configure your first sound level meter</div>
</td>
</tr>
`;
return;
}
tbody.innerHTML = devices.map(device => `
<tr>
<td><strong>${escapeHtml(device.unit_id)}</strong></td>
<td>${escapeHtml(device.host)}</td>
<td>${device.tcp_port}</td>
<td>${device.ftp_port || 21}</td>
<td class="checkbox-cell">
<input type="checkbox" ${device.tcp_enabled ? 'checked' : ''} disabled />
</td>
<td class="checkbox-cell">
<input type="checkbox" ${device.ftp_enabled ? 'checked' : ''} disabled />
</td>
<td class="checkbox-cell">
<input type="checkbox" ${device.poll_enabled ? 'checked' : ''} disabled />
</td>
<td>
${getStatusBadge(device)}
</td>
<td class="actions-cell">
<button class="btn btn-small" onclick="testDevice('${escapeHtml(device.unit_id)}')">Test</button>
<button class="btn btn-small" onclick="openEditModal('${escapeHtml(device.unit_id)}')">Edit</button>
<button class="btn btn-small btn-danger" onclick="deleteDevice('${escapeHtml(device.unit_id)}')">Delete</button>
</td>
</tr>
`).join('');
}
function getStatusBadge(device) {
if (!device.status) {
return '<span class="status-badge status-unknown">Unknown</span>';
}
if (device.status.is_reachable === false) {
return '<span class="status-badge status-error">Offline</span>';
}
if (device.status.last_success) {
const lastSeen = new Date(device.status.last_success);
const ago = Math.floor((Date.now() - lastSeen) / 1000);
if (ago < 300) { // Less than 5 minutes
return '<span class="status-badge status-ok">Online</span>';
} else {
return `<span class="status-badge status-unknown">Stale (${Math.floor(ago / 60)}m ago)</span>`;
}
}
return '<span class="status-badge status-unknown">Unknown</span>';
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return String(text).replace(/[&<>"']/g, m => map[m]);
}
function openAddModal() {
editingDeviceId = null;
document.getElementById('modalTitle').textContent = 'Add Device';
document.getElementById('deviceForm').reset();
document.getElementById('unitId').disabled = false;
document.getElementById('tcpEnabled').checked = true;
document.getElementById('ftpEnabled').checked = false;
document.getElementById('pollEnabled').checked = true;
document.getElementById('tcpPort').value = 2255;
document.getElementById('ftpPort').value = 21;
document.getElementById('pollInterval').value = 60;
toggleFtpCredentials();
document.getElementById('deviceModal').classList.add('active');
}
function openEditModal(unitId) {
const device = devices.find(d => d.unit_id === unitId);
if (!device) {
showToast('Device not found', 'error');
return;
}
editingDeviceId = unitId;
document.getElementById('modalTitle').textContent = 'Edit Device';
document.getElementById('unitId').value = device.unit_id;
document.getElementById('unitId').disabled = true;
document.getElementById('host').value = device.host;
document.getElementById('tcpPort').value = device.tcp_port;
document.getElementById('ftpPort').value = device.ftp_port || 21;
document.getElementById('tcpEnabled').checked = device.tcp_enabled;
document.getElementById('ftpEnabled').checked = device.ftp_enabled;
document.getElementById('ftpUsername').value = device.ftp_username || '';
document.getElementById('ftpPassword').value = device.ftp_password || '';
document.getElementById('pollEnabled').checked = device.poll_enabled;
document.getElementById('pollInterval').value = device.poll_interval_seconds || 60;
toggleFtpCredentials();
document.getElementById('deviceModal').classList.add('active');
}
function closeModal() {
document.getElementById('deviceModal').classList.remove('active');
editingDeviceId = null;
}
function toggleFtpCredentials() {
const ftpEnabled = document.getElementById('ftpEnabled').checked;
document.getElementById('ftpCredentialsSection').style.display = ftpEnabled ? 'block' : 'none';
}
async function saveDevice(event) {
event.preventDefault();
const unitId = document.getElementById('unitId').value.trim();
const payload = {
host: document.getElementById('host').value.trim(),
tcp_port: parseInt(document.getElementById('tcpPort').value),
ftp_port: parseInt(document.getElementById('ftpPort').value),
tcp_enabled: document.getElementById('tcpEnabled').checked,
ftp_enabled: document.getElementById('ftpEnabled').checked,
poll_enabled: document.getElementById('pollEnabled').checked,
poll_interval_seconds: parseInt(document.getElementById('pollInterval').value)
};
if (payload.ftp_enabled) {
const username = document.getElementById('ftpUsername').value.trim();
const password = document.getElementById('ftpPassword').value.trim();
if (username) payload.ftp_username = username;
if (password) payload.ftp_password = password;
}
try {
const url = editingDeviceId
? `/api/nl43/${editingDeviceId}/config`
: `/api/nl43/roster`;
const method = editingDeviceId ? 'PUT' : 'POST';
const body = editingDeviceId
? payload
: { unit_id: unitId, ...payload };
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (!res.ok) {
showToast(data.detail || 'Failed to save device', 'error');
return;
}
showToast(editingDeviceId ? 'Device updated successfully' : 'Device added successfully', 'success');
closeModal();
await loadRoster();
} catch (err) {
showToast('Error saving device: ' + err.message, 'error');
console.error('Save device error:', err);
}
}
async function deleteDevice(unitId) {
if (!confirm(`Are you sure you want to delete "${unitId}"?\n\nThis will remove the device configuration but will not affect the physical device.`)) {
return;
}
try {
const res = await fetch(`/api/nl43/${unitId}/config`, {
method: 'DELETE'
});
const data = await res.json();
if (!res.ok) {
showToast(data.detail || 'Failed to delete device', 'error');
return;
}
showToast('Device deleted successfully', 'success');
await loadRoster();
} catch (err) {
showToast('Error deleting device: ' + err.message, 'error');
console.error('Delete device error:', err);
}
}
async function testDevice(unitId) {
showToast('Testing device connection...', 'success');
try {
const res = await fetch(`/api/nl43/${unitId}/diagnostics`);
const data = await res.json();
if (!res.ok) {
showToast('Device test failed', 'error');
return;
}
const statusText = {
'pass': 'All systems operational ✓',
'fail': 'Connection failed ✗',
'degraded': 'Partial connectivity ⚠'
};
showToast(statusText[data.overall_status] || 'Test complete',
data.overall_status === 'pass' ? 'success' : 'error');
// Reload to update status
await loadRoster();
} catch (err) {
showToast('Error testing device: ' + err.message, 'error');
console.error('Test device error:', err);
}
}
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast toast-${type} active`;
setTimeout(() => {
toast.classList.remove('active');
}, 3000);
}
// Close modal when clicking outside
document.getElementById('deviceModal').addEventListener('click', (e) => {
if (e.target.id === 'deviceModal') {
closeModal();
}
});
</script>
</body>
</html>