Initial commit baby!
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Host ports (maps to the nginx container)
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
|
||||
# Database
|
||||
DB_PASSWORD=mikrotik_secure_pw
|
||||
|
||||
# JWT Authentication (change to a long random string)
|
||||
JWT_SECRET=changeme_use_a_long_random_secret_at_least_32_chars
|
||||
|
||||
# Device password encryption key (must be exactly 32 characters)
|
||||
ENCRYPTION_KEY=changeme32byteslongencryptionkey
|
||||
|
||||
# InfluxDB
|
||||
INFLUXDB_TOKEN=mytoken123456789
|
||||
INFLUXDB_ORG=mikrotik-manager
|
||||
INFLUXDB_BUCKET=metrics
|
||||
INFLUXDB_ADMIN_PASSWORD=admin_password_123
|
||||
@@ -633,8 +633,8 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
|
||||
@@ -1 +1,310 @@
|
||||
# mikrotik-manager
|
||||
# MikroTik Manager
|
||||
|
||||
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
> *Coming soon — screenshots will be added once the project reaches beta.*
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Dashboard
|
||||
- Live KPI cards: total devices, online/offline count, connected wireless clients, active alerts
|
||||
- Device type distribution chart
|
||||
- Firmware update notifications with per-device details
|
||||
- Historical client count graph (1h → 30d range)
|
||||
|
||||
### Device Management
|
||||
- Add, edit, and delete MikroTik devices (routers, switches, wireless APs)
|
||||
- Automatic polling: status, model, firmware version, RouterOS version
|
||||
- Firmware update availability detection
|
||||
- Per-device notes, rack location, and physical address with map support
|
||||
- Device credential encryption at rest
|
||||
|
||||
### Routers
|
||||
- Routing table viewer
|
||||
- Interface overview with IP assignments
|
||||
- Firewall rule inspection
|
||||
- Router-specific settings and configuration
|
||||
|
||||
### Switches
|
||||
- VLAN management (create, edit, delete VLANs)
|
||||
- Per-port configuration and VLAN membership
|
||||
- Switch overview with port status
|
||||
|
||||
### Wireless
|
||||
- Per-AP SSID management — create, edit, enable/disable, delete wireless interfaces
|
||||
- **Bulk SSID deployment** — push an SSID configuration to all managed APs simultaneously
|
||||
- Security profile management (WPA2/WPA3, PSK, EAP)
|
||||
- Hardware radio information and band filtering (RouterOS 7 wifi package + legacy wlan package)
|
||||
- Scheduled and on-demand **spectral scans** per radio
|
||||
- Scheduled and on-demand **AP scans** (nearby access point discovery)
|
||||
- Real-time radio monitoring
|
||||
- Wireless client tracking with vendor lookup
|
||||
|
||||
### Network Services
|
||||
Each service supports multi-device management with conflict detection:
|
||||
|
||||
| Service | Capabilities |
|
||||
|---|---|
|
||||
| **DHCP** | IPv4 & IPv6 servers, address pools, static leases, live lease table |
|
||||
| **DNS** | Upstream servers, static records (A/AAAA/CNAME/MX/NS/PTR/TXT/SRV), cache flush, DoH |
|
||||
| **NTP** | Server (broadcast/manycast), client (unicast/multicast), sync status |
|
||||
| **WireGuard** | Interface management, peer configuration, public key display, RX/TX stats |
|
||||
|
||||
### Network Topology
|
||||
- Auto-discovered network map using LLDP, CDP, and MNDP neighbor data
|
||||
- Interactive node graph with device type icons
|
||||
- Protocol-priority link deduplication
|
||||
|
||||
### Client Tracking
|
||||
- All connected clients across all devices in one view
|
||||
- Filter by device, type, active status, or search by MAC/IP/hostname
|
||||
- Client detail page with connection history and vendor identification
|
||||
- Historical client count metrics
|
||||
|
||||
### Backups
|
||||
- Trigger RouterOS backups on demand via SSH
|
||||
- Download and manage backup files from the UI
|
||||
|
||||
### Alerts
|
||||
Configurable alert rules with cooldown periods:
|
||||
- Device online / offline
|
||||
- High CPU or memory usage (configurable threshold)
|
||||
- SSL certificate expiry warning
|
||||
- Firmware update available
|
||||
- RouterOS log errors and warnings
|
||||
- New device discovered
|
||||
|
||||
### Global Search
|
||||
Instant search across devices, clients, and events from the top navigation bar.
|
||||
|
||||
### User Management & Access Control
|
||||
- Role-based access: **Admin**, **Operator** (read/write), **Viewer** (read-only)
|
||||
- Admin-only user creation and role assignment
|
||||
- JWT authentication with secure session handling
|
||||
|
||||
### TLS / HTTPS
|
||||
- Automatic self-signed certificate generation on first run
|
||||
- Upload a custom certificate and private key via the Settings UI
|
||||
- nginx handles TLS termination and HTTP→HTTPS redirect
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| **Frontend** | React 18, TypeScript, Vite, Tailwind CSS |
|
||||
| **State / Data** | TanStack Query v5, React Router v6, Zustand |
|
||||
| **Charts** | Recharts |
|
||||
| **Topology** | @xyflow/react |
|
||||
| **Maps** | Leaflet |
|
||||
| **Terminal** | xterm.js |
|
||||
| **Backend** | Node.js, Express, TypeScript |
|
||||
| **Primary DB** | PostgreSQL 15 |
|
||||
| **Time-series DB** | InfluxDB 2.7 |
|
||||
| **Cache / Queue** | Redis 7, BullMQ |
|
||||
| **Real-time** | Socket.IO |
|
||||
| **Device comms** | RouterOS API (port 8728), SSH2 |
|
||||
| **Proxy** | nginx (TLS termination, static file serving) |
|
||||
| **Container** | Docker Compose |
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) (v2+)
|
||||
- MikroTik devices running **RouterOS 6.x or 7.x** with the API service enabled
|
||||
- Network access from the host running this application to your MikroTik devices on port **8728** (or your configured API port)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/mikrotik-manager.git
|
||||
cd mikrotik-manager
|
||||
```
|
||||
|
||||
### 2. Configure environment variables
|
||||
|
||||
Copy the example environment file and edit it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
At minimum, change these values in `.env`:
|
||||
|
||||
```env
|
||||
# Required — use long, random strings
|
||||
JWT_SECRET=your_long_random_jwt_secret_here
|
||||
ENCRYPTION_KEY=your_32_character_encryption_key_
|
||||
|
||||
# Optional — defaults work for a local install
|
||||
DB_PASSWORD=mikrotik_secure_pw
|
||||
INFLUXDB_TOKEN=mytoken123456789
|
||||
```
|
||||
|
||||
> **Security note:** Never commit your `.env` file to version control. The `.gitignore` already excludes it.
|
||||
|
||||
### 3. Start the application
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The first run will:
|
||||
- Build the frontend (React → static files)
|
||||
- Build the backend (TypeScript → Node.js)
|
||||
- Initialize PostgreSQL with the database schema
|
||||
- Initialize InfluxDB
|
||||
- Generate a self-signed TLS certificate
|
||||
|
||||
### 4. Open the app
|
||||
|
||||
Navigate to **https://localhost** (or your server's IP/hostname).
|
||||
|
||||
Accept the browser's self-signed certificate warning, or upload a real certificate in **Settings → TLS Certificate**.
|
||||
|
||||
### 5. Log in
|
||||
|
||||
Default credentials on first run:
|
||||
|
||||
| Username | Password | Role |
|
||||
|---|---|---|
|
||||
| `admin` | `admin` | Admin |
|
||||
|
||||
**Change the default password immediately** in Settings → Users.
|
||||
|
||||
---
|
||||
|
||||
## Enabling the RouterOS API
|
||||
|
||||
On each MikroTik device you want to manage, ensure the API service is enabled:
|
||||
|
||||
```
|
||||
/ip service enable api
|
||||
```
|
||||
|
||||
For API over SSL (port 8729):
|
||||
```
|
||||
/ip service enable api-ssl
|
||||
```
|
||||
|
||||
The default API port is `8728`. You can configure a different port per device in the MikroTik Manager interface.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
All configuration is done via environment variables in `.env`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `JWT_SECRET` | `changeme_use_a_long_random_secret_at_least_32_chars` | Secret for signing JWT tokens. **Change this.** |
|
||||
| `ENCRYPTION_KEY` | `changeme32byteslongencryptionkey` | 32-character key for encrypting device passwords at rest. **Change this.** |
|
||||
| `DB_PASSWORD` | `mikrotik_secure_pw` | PostgreSQL password |
|
||||
| `INFLUXDB_TOKEN` | `mytoken123456789` | InfluxDB admin token |
|
||||
| `INFLUXDB_ORG` | `mikrotik-manager` | InfluxDB organization name |
|
||||
| `INFLUXDB_BUCKET` | `metrics` | InfluxDB bucket for time-series data |
|
||||
| `INFLUXDB_ADMIN_PASSWORD` | `admin_password_123` | InfluxDB admin UI password |
|
||||
| `HTTP_PORT` | `80` | Host port for HTTP (redirects to HTTPS) |
|
||||
| `HTTPS_PORT` | `443` | Host port for HTTPS |
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
Pull the latest changes and rebuild:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build backend nginx
|
||||
```
|
||||
|
||||
Database migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mikrotik-manager/
|
||||
├── frontend/ # React + TypeScript (Vite)
|
||||
│ └── src/
|
||||
│ ├── pages/ # Page components (one per route)
|
||||
│ ├── components/ # Shared UI components
|
||||
│ ├── services/ # API client (Axios)
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ └── types/ # TypeScript type definitions
|
||||
│
|
||||
├── backend/ # Node.js + Express + TypeScript
|
||||
│ └── src/
|
||||
│ ├── routes/ # REST API route handlers
|
||||
│ ├── services/ # Business logic (polling, alerts, backups)
|
||||
│ │ └── mikrotik/ # RouterOS API client and device collector
|
||||
│ ├── db/ # Database migrations
|
||||
│ ├── config/ # DB, InfluxDB, Redis connections
|
||||
│ ├── middleware/ # Auth, error handling
|
||||
│ └── utils/ # Helpers (crypto, OUI lookup, etc.)
|
||||
│
|
||||
├── nginx/ # Reverse proxy config and Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Email / webhook notifications for alerts
|
||||
- [ ] Multi-site / location grouping
|
||||
- [ ] Scheduled configuration backups
|
||||
- [ ] RouterOS script execution
|
||||
- [ ] Dark/light theme toggle
|
||||
- [ ] Mobile-optimized layout
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please open an issue before submitting a pull request so we can discuss the approach.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feature/your-feature`
|
||||
3. Commit your changes: `git commit -m "Add your feature"`
|
||||
4. Push to the branch: `git push origin feature/your-feature`
|
||||
5. Open a pull request
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **GNU Affero General Public License v3.0 (AGPLv3)** — see the [LICENSE](LICENSE) file for the full text.
|
||||
|
||||
### What this means
|
||||
|
||||
- You are free to use, modify, and distribute this software.
|
||||
- If you run a modified version of this software as a network service (e.g., as a hosted web app), you **must** make your modified source code available to users of that service under the same AGPLv3 license.
|
||||
- Any distributed copies or derivatives must also carry the AGPLv3 license.
|
||||
|
||||
This license was chosen to ensure that improvements made to this project — including those deployed as a service — remain open and available to the community.
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is not affiliated with or endorsed by MikroTik. MikroTik and RouterOS are trademarks of SIA MikroTīkls. Use this software at your own risk. Always test configuration changes in a non-production environment first.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only production dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev && npm cache clean --force
|
||||
|
||||
# Install arp-scan for server-side MAC→IP ARP discovery.
|
||||
# libcap is needed to set file capabilities so it runs without root.
|
||||
# openssl is needed for self-signed certificate generation.
|
||||
RUN apk add --no-cache arp-scan libcap openssl \
|
||||
&& setcap cap_net_raw+ep /usr/bin/arp-scan
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create backups directory
|
||||
RUN mkdir -p /app/backups
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
RUN chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Mikrotik Network Management Platform - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts",
|
||||
"migrate": "ts-node src/db/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"compression": "^1.7.4",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"pg": "^8.11.3",
|
||||
"@influxdata/influxdb-client": "^1.33.2",
|
||||
"ioredis": "^5.3.2",
|
||||
"bullmq": "^5.1.1",
|
||||
"socket.io": "^4.6.1",
|
||||
"ssh2": "^1.14.0",
|
||||
"zod": "^3.22.4",
|
||||
"dotenv": "^16.3.1",
|
||||
"morgan": "^1.10.0",
|
||||
"nodemailer": "^6.9.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/ssh2": "^1.11.17",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.10.5",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"nodemon": "^3.0.2",
|
||||
"@types/nodemailer": "^6.4.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Pool } from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('PostgreSQL pool error:', err);
|
||||
});
|
||||
|
||||
export async function query<T = Record<string, unknown>>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<T[]> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const result = await client.query(text, params);
|
||||
return result.rows as T[];
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryOne<T = Record<string, unknown>>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<T | null> {
|
||||
const rows = await query<T>(text, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function transaction<T>(
|
||||
fn: (client: import('pg').PoolClient) => Promise<T>
|
||||
): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await fn(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { InfluxDB, WriteApi, QueryApi } from '@influxdata/influxdb-client';
|
||||
|
||||
const url = process.env.INFLUXDB_URL || 'http://influxdb:8086';
|
||||
const token = process.env.INFLUXDB_TOKEN || '';
|
||||
const org = process.env.INFLUXDB_ORG || 'mikrotik-manager';
|
||||
const bucket = process.env.INFLUXDB_BUCKET || 'metrics';
|
||||
|
||||
let influxClient: InfluxDB | null = null;
|
||||
let writeClient: WriteApi | null = null;
|
||||
let queryClient: QueryApi | null = null;
|
||||
|
||||
export function getInfluxClient(): InfluxDB {
|
||||
if (!influxClient) {
|
||||
influxClient = new InfluxDB({ url, token });
|
||||
}
|
||||
return influxClient;
|
||||
}
|
||||
|
||||
export function getWriteApi(): WriteApi {
|
||||
if (!writeClient) {
|
||||
writeClient = getInfluxClient().getWriteApi(org, bucket, 'ns');
|
||||
writeClient.useDefaultTags({ source: 'mikrotik-manager' });
|
||||
}
|
||||
return writeClient;
|
||||
}
|
||||
|
||||
export function getQueryApi(): QueryApi {
|
||||
if (!queryClient) {
|
||||
queryClient = getInfluxClient().getQueryApi(org);
|
||||
}
|
||||
return queryClient;
|
||||
}
|
||||
|
||||
export { bucket, org };
|
||||
@@ -0,0 +1,26 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redisUrl = process.env.REDIS_URL || 'redis://redis:6379';
|
||||
|
||||
export const redis = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
console.error('Redis error:', err.message);
|
||||
});
|
||||
|
||||
redis.on('connect', () => {
|
||||
console.log('Redis connected');
|
||||
});
|
||||
|
||||
// Separate connection for BullMQ (requires maxRetriesPerRequest: null)
|
||||
export function createRedisConnection(): Redis {
|
||||
return new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { pool } from '../config/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const MIGRATION_SQL = `
|
||||
-- Users
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'admin',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Devices
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
api_port INTEGER NOT NULL DEFAULT 8728,
|
||||
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||
api_username VARCHAR(50) NOT NULL,
|
||||
api_password_encrypted TEXT NOT NULL,
|
||||
ssh_username VARCHAR(50),
|
||||
ssh_password_encrypted TEXT,
|
||||
model VARCHAR(100),
|
||||
serial_number VARCHAR(50),
|
||||
firmware_version VARCHAR(50),
|
||||
ros_version VARCHAR(20),
|
||||
device_type VARCHAR(20) DEFAULT 'router',
|
||||
status VARCHAR(20) DEFAULT 'unknown',
|
||||
last_seen TIMESTAMPTZ,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Device full config snapshots
|
||||
CREATE TABLE IF NOT EXISTS device_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
config_json JSONB NOT NULL,
|
||||
collected_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_configs_device ON device_configs(device_id, collected_at DESC);
|
||||
|
||||
-- Network interfaces
|
||||
CREATE TABLE IF NOT EXISTS interfaces (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
type VARCHAR(30),
|
||||
mac_address VARCHAR(17),
|
||||
mtu INTEGER,
|
||||
running BOOLEAN DEFAULT FALSE,
|
||||
disabled BOOLEAN DEFAULT FALSE,
|
||||
comment TEXT,
|
||||
speed VARCHAR(20),
|
||||
full_duplex BOOLEAN,
|
||||
config_json JSONB,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, name)
|
||||
);
|
||||
|
||||
-- VLANs
|
||||
CREATE TABLE IF NOT EXISTS vlans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
vlan_id INTEGER NOT NULL,
|
||||
name VARCHAR(100),
|
||||
bridge VARCHAR(50),
|
||||
tagged_ports TEXT[],
|
||||
untagged_ports TEXT[],
|
||||
config_json JSONB,
|
||||
UNIQUE(device_id, vlan_id)
|
||||
);
|
||||
|
||||
-- Bridge VLAN table entries (for switch port VLAN mapping)
|
||||
CREATE TABLE IF NOT EXISTS bridge_vlan_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
bridge VARCHAR(50) NOT NULL,
|
||||
port VARCHAR(50) NOT NULL,
|
||||
vlan_ids TEXT[],
|
||||
pvid INTEGER,
|
||||
tagged BOOLEAN DEFAULT FALSE,
|
||||
config_json JSONB,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, bridge, port)
|
||||
);
|
||||
|
||||
-- Network clients (ARP/DHCP/wireless leases)
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
mac_address VARCHAR(17) NOT NULL,
|
||||
hostname VARCHAR(255),
|
||||
ip_address VARCHAR(45),
|
||||
interface_name VARCHAR(50),
|
||||
tx_bytes BIGINT DEFAULT 0,
|
||||
rx_bytes BIGINT DEFAULT 0,
|
||||
signal_strength INTEGER,
|
||||
comment TEXT,
|
||||
client_type VARCHAR(20) DEFAULT 'wired',
|
||||
active BOOLEAN DEFAULT FALSE,
|
||||
last_seen TIMESTAMPTZ,
|
||||
UNIQUE(device_id, mac_address)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_active ON clients(active, last_seen DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_mac ON clients(mac_address);
|
||||
|
||||
-- Events and alerts
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
|
||||
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
severity VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||
topic VARCHAR(100),
|
||||
message TEXT NOT NULL,
|
||||
raw_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_device_time ON events(device_id, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_time ON events(event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_severity ON events(severity, event_time DESC);
|
||||
|
||||
-- Backups
|
||||
CREATE TABLE IF NOT EXISTS backups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
size_bytes INTEGER,
|
||||
backup_type VARCHAR(20) DEFAULT 'manual',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Topology links (discovered via LLDP/CDP/neighbor)
|
||||
CREATE TABLE IF NOT EXISTS topology_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
|
||||
from_interface VARCHAR(50),
|
||||
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
|
||||
to_interface VARCHAR(50),
|
||||
neighbor_address VARCHAR(45),
|
||||
neighbor_identity VARCHAR(255),
|
||||
neighbor_platform VARCHAR(255),
|
||||
link_type VARCHAR(20) DEFAULT 'lldp',
|
||||
discovered_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(from_device_id, from_interface)
|
||||
);
|
||||
|
||||
-- Application settings
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Incremental schema updates
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS vendor VARCHAR(255);
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS vlan_id INTEGER;
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_name VARCHAR(255);
|
||||
ALTER TABLE events ADD COLUMN IF NOT EXISTS log_id VARCHAR(20);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_device_log_id ON events(device_id, log_id);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_mac VARCHAR(17);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS stp_role VARCHAR(20);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS stp_state VARCHAR(20);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS bridge_name VARCHAR(50);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS location_address TEXT;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS location_lat NUMERIC(10,7);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS location_lng NUMERIC(10,7);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS rack_name VARCHAR(100);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS rack_slot VARCHAR(20);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_caps VARCHAR(255);
|
||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS discovered_by VARCHAR(50);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS firmware_update_available BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS latest_ros_version VARCHAR(20);
|
||||
|
||||
-- Allow multiple neighbors per interface (one row per neighbor, not per port)
|
||||
ALTER TABLE topology_links DROP CONSTRAINT IF EXISTS topology_links_from_device_id_from_interface_key;
|
||||
|
||||
-- Alert rules — one row per event type
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
event_type VARCHAR(50) PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
threshold INTEGER,
|
||||
cooldown_min INTEGER NOT NULL DEFAULT 15,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Alert channels — email / Slack / Discord / Telegram
|
||||
CREATE TABLE IF NOT EXISTS alert_channels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('email','slack','discord','telegram')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Alert send history
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
|
||||
device_name VARCHAR(255),
|
||||
message TEXT NOT NULL,
|
||||
channels_notified JSONB,
|
||||
sent_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_sent ON alert_history(sent_at DESC);
|
||||
|
||||
-- Wireless interfaces (radio hardware config + SSID settings)
|
||||
CREATE TABLE IF NOT EXISTS wireless_interfaces (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
ssid VARCHAR(100),
|
||||
mode VARCHAR(30),
|
||||
band VARCHAR(50),
|
||||
frequency INTEGER,
|
||||
channel_width VARCHAR(30),
|
||||
tx_power INTEGER,
|
||||
tx_power_mode VARCHAR(30),
|
||||
antenna_gain INTEGER,
|
||||
country VARCHAR(50),
|
||||
installation VARCHAR(20) DEFAULT 'indoor',
|
||||
disabled BOOLEAN DEFAULT FALSE,
|
||||
running BOOLEAN DEFAULT FALSE,
|
||||
mac_address VARCHAR(17),
|
||||
security_profile VARCHAR(100),
|
||||
noise_floor INTEGER,
|
||||
registered_clients INTEGER DEFAULT 0,
|
||||
config_json JSONB,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, name)
|
||||
);
|
||||
|
||||
-- Spectral scan snapshots
|
||||
CREATE TABLE IF NOT EXISTS spectral_scan_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
scanned_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
data JSONB NOT NULL,
|
||||
scan_type TEXT DEFAULT 'scheduled'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_spectral_scan_device
|
||||
ON spectral_scan_data(device_id, interface_name, scanned_at DESC);
|
||||
|
||||
-- AP scan results (nearby access points discovered by wireless scan)
|
||||
CREATE TABLE IF NOT EXISTS ap_scan_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
scanned_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
data JSONB NOT NULL,
|
||||
scan_type TEXT DEFAULT 'scheduled'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ap_scan_device
|
||||
ON ap_scan_data(device_id, scanned_at DESC);
|
||||
|
||||
-- Wireless security profiles (WPA/WPA2/WPA3 config)
|
||||
CREATE TABLE IF NOT EXISTS wireless_security_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
mode VARCHAR(30) DEFAULT 'none',
|
||||
authentication_types TEXT[] DEFAULT '{}',
|
||||
unicast_ciphers TEXT[] DEFAULT '{}',
|
||||
group_ciphers TEXT[] DEFAULT '{}',
|
||||
management_protection VARCHAR(20) DEFAULT 'disabled',
|
||||
config_json JSONB,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(device_id, name)
|
||||
);
|
||||
`;
|
||||
|
||||
const DEFAULT_SETTINGS = [
|
||||
{ key: 'polling_fast_interval', value: 30 },
|
||||
{ key: 'polling_slow_interval', value: 300 },
|
||||
{ key: 'polling_logs_interval', value: 60 },
|
||||
{ key: 'retention_events_days', value: 30 },
|
||||
{ key: 'backup_schedule_enabled', value: false },
|
||||
{ key: 'backup_schedule_cron', value: '0 2 * * *' },
|
||||
{ key: 'mac_scan_enabled', value: true },
|
||||
{ key: 'mac_scan_interval', value: 300 },
|
||||
{ key: 'reverse_dns_enabled', value: false },
|
||||
{ key: 'retention_clients_days', value: 7 },
|
||||
{ key: 'spectral_scan_enabled', value: false },
|
||||
{ key: 'spectral_scan_interval_hours', value: 24 },
|
||||
{ key: 'ap_scan_enabled', value: false },
|
||||
{ key: 'ap_scan_interval_hours', value: 24 },
|
||||
];
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
console.log('Running database migrations...');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(MIGRATION_SQL);
|
||||
console.log('Schema created/verified');
|
||||
|
||||
// Insert default settings
|
||||
for (const setting of DEFAULT_SETTINGS) {
|
||||
await client.query(
|
||||
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
|
||||
[setting.key, JSON.stringify(setting.value)]
|
||||
);
|
||||
}
|
||||
|
||||
// Create default admin user if no users exist
|
||||
const userCount = await client.query('SELECT COUNT(*) FROM users');
|
||||
if (parseInt(userCount.rows[0].count, 10) === 0) {
|
||||
const hash = await bcrypt.hash('admin', 12);
|
||||
await client.query(
|
||||
`INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)`,
|
||||
['admin', hash, 'admin']
|
||||
);
|
||||
console.log('Default admin user created (username: admin, password: admin)');
|
||||
console.log('⚠️ Please change the default password after first login!');
|
||||
}
|
||||
|
||||
console.log('Database migrations completed successfully');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Run directly if called as script
|
||||
if (require.main === module) {
|
||||
runMigrations()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import 'express-async-errors';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import morgan from 'morgan';
|
||||
import { createServer } from 'http';
|
||||
import { Server as SocketServer } from 'socket.io';
|
||||
import { Client as SshClient } from 'ssh2';
|
||||
import type { ClientChannel } from 'ssh2';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
import { pool, queryOne, query } from './config/database';
|
||||
import { initOuiDatabase } from './utils/oui';
|
||||
import { redis } from './config/redis';
|
||||
import { runMigrations } from './db/migrate';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { PollerService } from './services/PollerService';
|
||||
import { verifyToken } from './middleware/auth';
|
||||
import { decrypt } from './utils/crypto';
|
||||
|
||||
import authRoutes from './routes/auth';
|
||||
import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/devices';
|
||||
import clientsRoutes, { setPollerService as setClientsPoller } from './routes/clients';
|
||||
import eventsRoutes from './routes/events';
|
||||
import backupsRoutes from './routes/backups';
|
||||
import metricsRoutes from './routes/metrics';
|
||||
import topologyRoutes, { setPollerService as setTopologyPoller } from './routes/topology';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import certRoutes from './routes/cert';
|
||||
import searchRoutes from './routes/search';
|
||||
import switchesRoutes from './routes/switches';
|
||||
import routersRoutes from './routes/routers';
|
||||
import alertsRoutes from './routes/alerts';
|
||||
import wirelessRoutes from './routes/wireless';
|
||||
import networkServicesRoutes from './routes/networkServices';
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
|
||||
// ─── Socket.io ────────────────────────────────────────────────────────────────
|
||||
const io = new SocketServer(httpServer, {
|
||||
cors: { origin: '*', methods: ['GET', 'POST'] },
|
||||
path: '/socket.io',
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`Socket connected: ${socket.id}`);
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Socket disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SSH Terminal namespace ────────────────────────────────────────────────────
|
||||
const terminalNs = io.of('/terminal');
|
||||
|
||||
terminalNs.use((socket, next) => {
|
||||
const token = (socket.handshake.auth as { token?: string })?.token;
|
||||
if (!token) return next(new Error('No token'));
|
||||
try {
|
||||
verifyToken(token);
|
||||
next();
|
||||
} catch {
|
||||
next(new Error('Invalid or expired token'));
|
||||
}
|
||||
});
|
||||
|
||||
terminalNs.on('connection', (socket) => {
|
||||
let sshClient: SshClient | null = null;
|
||||
let shellStream: ClientChannel | null = null;
|
||||
|
||||
socket.on('start', async (payload: { deviceId: number; cols?: number; rows?: number }) => {
|
||||
const { deviceId, cols = 80, rows = 24 } = payload;
|
||||
try {
|
||||
const device = await queryOne<{
|
||||
ip_address: string;
|
||||
ssh_port: number | null;
|
||||
ssh_username: string | null;
|
||||
ssh_password_encrypted: string | null;
|
||||
}>(
|
||||
`SELECT ip_address, ssh_port, ssh_username, ssh_password_encrypted FROM devices WHERE id = $1`,
|
||||
[deviceId]
|
||||
);
|
||||
|
||||
if (!device) { socket.emit('error', 'Device not found'); return; }
|
||||
if (!device.ssh_username || !device.ssh_password_encrypted) {
|
||||
socket.emit('error', 'No SSH credentials configured for this device. Add an SSH username and password in device settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const password = decrypt(device.ssh_password_encrypted);
|
||||
sshClient = new SshClient();
|
||||
|
||||
sshClient.on('ready', () => {
|
||||
sshClient!.shell(
|
||||
{ term: 'xterm-256color', cols, rows },
|
||||
(err, stream) => {
|
||||
if (err) { socket.emit('error', err.message); return; }
|
||||
shellStream = stream;
|
||||
socket.emit('ready');
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
socket.emit('data', data.toString('binary'));
|
||||
});
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
socket.emit('data', data.toString('binary'));
|
||||
});
|
||||
stream.on('close', () => {
|
||||
socket.emit('close');
|
||||
sshClient?.end();
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
sshClient.on('error', (err) => {
|
||||
socket.emit('error', `SSH error: ${err.message}`);
|
||||
});
|
||||
|
||||
sshClient.connect({
|
||||
host: device.ip_address,
|
||||
port: device.ssh_port ?? 22,
|
||||
username: device.ssh_username,
|
||||
password,
|
||||
readyTimeout: 10_000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
'ecdh-sha2-nistp256',
|
||||
'ecdh-sha2-nistp384',
|
||||
'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
],
|
||||
serverHostKey: [
|
||||
'ssh-rsa',
|
||||
'ecdsa-sha2-nistp256',
|
||||
'ecdsa-sha2-nistp384',
|
||||
'ssh-ed25519',
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
socket.emit('error', `Connection failed: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('data', (data: string) => {
|
||||
shellStream?.write(data);
|
||||
});
|
||||
|
||||
socket.on('resize', ({ cols, rows }: { cols: number; rows: number }) => {
|
||||
shellStream?.setWindow(rows, cols, 0, 0);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
shellStream?.end();
|
||||
sshClient?.end();
|
||||
shellStream = null;
|
||||
sshClient = null;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Middleware ───────────────────────────────────────────────────────────────
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.use(cors({ origin: '*', credentials: true }));
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.use(morgan('combined'));
|
||||
}
|
||||
|
||||
// ─── Health Check ─────────────────────────────────────────────────────────────
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/devices', devicesRoutes);
|
||||
app.use('/api/clients', clientsRoutes);
|
||||
app.use('/api/events', eventsRoutes);
|
||||
app.use('/api/backups', backupsRoutes);
|
||||
app.use('/api/metrics', metricsRoutes);
|
||||
app.use('/api/topology', topologyRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/cert', certRoutes);
|
||||
app.use('/api/search', searchRoutes);
|
||||
app.use('/api/switches', switchesRoutes);
|
||||
app.use('/api/routers', routersRoutes);
|
||||
app.use('/api/alerts', alertsRoutes);
|
||||
app.use('/api/wireless', wirelessRoutes);
|
||||
app.use('/api/network-services', networkServicesRoutes);
|
||||
|
||||
// ─── Error Handler ────────────────────────────────────────────────────────────
|
||||
app.use(errorHandler);
|
||||
|
||||
// ─── Startup ─────────────────────────────────────────────────────────────────
|
||||
async function start(): Promise<void> {
|
||||
// Wait for DB to be ready
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
break;
|
||||
} catch (err) {
|
||||
if (i === 9) throw err;
|
||||
console.log(`Waiting for database... (${i + 1}/10)`);
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
await runMigrations();
|
||||
|
||||
// Reset vendor entries that were previously set to '' due to API rate-limiting,
|
||||
// so they get re-resolved by the new local OUI database.
|
||||
await query(`UPDATE clients SET vendor = NULL WHERE vendor = ''`).catch(() => {});
|
||||
|
||||
// Start loading the OUI database in the background (doesn't block startup)
|
||||
initOuiDatabase().catch(() => {});
|
||||
|
||||
// Connect Redis
|
||||
await redis.connect().catch(() => console.warn('Redis connection warning'));
|
||||
|
||||
// Start poller
|
||||
const pollerService = new PollerService();
|
||||
pollerService.setSocketServer(io);
|
||||
setDevicesPoller(pollerService);
|
||||
setTopologyPoller(pollerService);
|
||||
setClientsPoller(pollerService);
|
||||
await pollerService.start();
|
||||
|
||||
// Start HTTP server
|
||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`✓ Mikrotik Manager backend running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down...');
|
||||
await pollerService.stop();
|
||||
await redis.quit().catch(() => {});
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error('Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface AuthPayload {
|
||||
userId: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
|
||||
|
||||
export function signToken(payload: AuthPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): AuthPayload {
|
||||
return jwt.verify(token, JWT_SECRET) as AuthPayload;
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
req.user = verifyToken(token);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireWrite(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.user || req.user.role === 'viewer') {
|
||||
res.status(403).json({ error: 'Write access denied for viewer role' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
): void {
|
||||
console.error(`[${req.method} ${req.path}]`, err.message);
|
||||
|
||||
if (res.headersSent) return;
|
||||
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
res.status(status).json({
|
||||
error: err.message || 'Internal server error',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { alertService } from '../services/AlertService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All alert routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// ── Default rules seed ─────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_RULES = [
|
||||
{ event_type: 'device_offline', enabled: true, threshold: null, cooldown_min: 5 },
|
||||
{ event_type: 'device_online', enabled: true, threshold: null, cooldown_min: 5 },
|
||||
{ event_type: 'log_error', enabled: false, threshold: null, cooldown_min: 15 },
|
||||
{ event_type: 'log_warning', enabled: false, threshold: null, cooldown_min: 30 },
|
||||
{ event_type: 'high_cpu', enabled: false, threshold: 90, cooldown_min: 30 },
|
||||
{ event_type: 'high_memory', enabled: false, threshold: 90, cooldown_min: 30 },
|
||||
{ event_type: 'cert_expiry', enabled: false, threshold: 14, cooldown_min: 1440 },
|
||||
{ event_type: 'device_discovered', enabled: false, threshold: null, cooldown_min: 60 },
|
||||
{ event_type: 'firmware_update_available', enabled: true, threshold: null, cooldown_min: 1440 },
|
||||
];
|
||||
|
||||
async function ensureDefaultRules(): Promise<void> {
|
||||
for (const rule of DEFAULT_RULES) {
|
||||
await query(
|
||||
`INSERT INTO alert_rules (event_type, enabled, threshold, cooldown_min)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (event_type) DO NOTHING`,
|
||||
[rule.event_type, rule.enabled, rule.threshold, rule.cooldown_min]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alert Rules ────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/alerts/rules — list all rules (seeds defaults on first call) */
|
||||
router.get('/rules', async (_req, res) => {
|
||||
await ensureDefaultRules();
|
||||
const rules = await query(
|
||||
`SELECT event_type, enabled, threshold, cooldown_min, updated_at
|
||||
FROM alert_rules ORDER BY event_type`
|
||||
);
|
||||
res.json(rules);
|
||||
});
|
||||
|
||||
/** PUT /api/alerts/rules/:type — update a single rule */
|
||||
router.put('/rules/:type', requireWrite, async (req, res) => {
|
||||
const { type } = req.params;
|
||||
const { enabled, threshold, cooldown_min } = req.body;
|
||||
|
||||
const row = await alertService.upsertRule(type, {
|
||||
enabled: enabled !== undefined ? Boolean(enabled) : undefined,
|
||||
threshold: threshold !== undefined ? (Number(threshold) || null) : undefined,
|
||||
cooldown_min: cooldown_min !== undefined ? Number(cooldown_min) : undefined,
|
||||
});
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
// ── Alert Channels ─────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/alerts/channels */
|
||||
router.get('/channels', async (_req, res) => {
|
||||
const channels = await query(
|
||||
`SELECT id, name, type, enabled, config, created_at, updated_at
|
||||
FROM alert_channels ORDER BY id`
|
||||
);
|
||||
// Mask sensitive fields before returning
|
||||
const masked = channels.map((ch: Record<string, unknown>) => ({
|
||||
...ch,
|
||||
config: maskConfig(ch.type as string, ch.config as Record<string, unknown>),
|
||||
}));
|
||||
res.json(masked);
|
||||
});
|
||||
|
||||
/** POST /api/alerts/channels */
|
||||
router.post('/channels', requireWrite, async (req, res) => {
|
||||
const { name, type, enabled = true, config = {} } = req.body;
|
||||
if (!name || !type) {
|
||||
return res.status(400).json({ error: 'name and type are required' });
|
||||
}
|
||||
const validTypes = ['email', 'slack', 'discord', 'telegram'];
|
||||
if (!validTypes.includes(type)) {
|
||||
return res.status(400).json({ error: `type must be one of: ${validTypes.join(', ')}` });
|
||||
}
|
||||
|
||||
const rows = await query(
|
||||
`INSERT INTO alert_channels (name, type, enabled, config)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[name, type, Boolean(enabled), JSON.stringify(config)]
|
||||
);
|
||||
const ch = rows[0] as Record<string, unknown>;
|
||||
res.status(201).json({
|
||||
...ch,
|
||||
config: maskConfig(type, config),
|
||||
});
|
||||
});
|
||||
|
||||
/** PUT /api/alerts/channels/:id */
|
||||
router.put('/channels/:id', requireWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const { name, enabled, config } = req.body;
|
||||
|
||||
// Fetch existing so we can merge config (preserve masked/unchanged fields)
|
||||
const existing = await query<{ config: Record<string, unknown>; type: string }>(
|
||||
`SELECT config, type FROM alert_channels WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!existing[0]) return res.status(404).json({ error: 'Channel not found' });
|
||||
|
||||
const existingConfig = existing[0].config as Record<string, unknown>;
|
||||
const type = existing[0].type;
|
||||
|
||||
// Merge new config over existing, preserving existing passwords when placeholders sent
|
||||
const mergedConfig = mergeConfig(type, existingConfig, config ?? {});
|
||||
|
||||
const rows = await query(
|
||||
`UPDATE alert_channels
|
||||
SET name = COALESCE($2, name),
|
||||
enabled = COALESCE($3, enabled),
|
||||
config = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, name ?? null, enabled !== undefined ? Boolean(enabled) : null, JSON.stringify(mergedConfig)]
|
||||
);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Channel not found' });
|
||||
|
||||
const ch = rows[0] as Record<string, unknown>;
|
||||
res.json({ ...ch, config: maskConfig(type, mergedConfig) });
|
||||
});
|
||||
|
||||
/** DELETE /api/alerts/channels/:id */
|
||||
router.delete('/channels/:id', requireWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
await query(`DELETE FROM alert_channels WHERE id = $1`, [id]);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
/** POST /api/alerts/channels/:id/test */
|
||||
router.post('/channels/:id/test', requireWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
try {
|
||||
await alertService.testChannel(id);
|
||||
res.json({ message: 'Test alert sent successfully' });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Alert History ──────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/alerts/history?limit=50 */
|
||||
router.get('/history', async (req, res) => {
|
||||
const limit = Math.min(parseInt(String(req.query.limit ?? 50), 10), 200);
|
||||
const rows = await query(
|
||||
`SELECT h.id, h.event_type, h.device_id, h.device_name, h.message,
|
||||
h.channels_notified, h.sent_at
|
||||
FROM alert_history h
|
||||
ORDER BY h.sent_at DESC LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SENSITIVE_KEYS: Record<string, string[]> = {
|
||||
email: ['smtp_pass'],
|
||||
slack: [],
|
||||
discord: [],
|
||||
telegram: ['bot_token'],
|
||||
};
|
||||
|
||||
function maskConfig(type: string, config: Record<string, unknown>): Record<string, unknown> {
|
||||
const masked = { ...config };
|
||||
for (const key of SENSITIVE_KEYS[type] ?? []) {
|
||||
if (masked[key]) masked[key] = '••••••••';
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
/** When updating, if a sensitive field comes back as the mask placeholder, keep the original. */
|
||||
function mergeConfig(
|
||||
type: string,
|
||||
existing: Record<string, unknown>,
|
||||
incoming: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const merged = { ...existing, ...incoming };
|
||||
for (const key of SENSITIVE_KEYS[type] ?? []) {
|
||||
if (incoming[key] === '••••••••') {
|
||||
merged[key] = existing[key]; // restore original
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { signToken, requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const user = await queryOne<{
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
}>(`SELECT id, username, password_hash, role FROM users WHERE username = $1`, [username]);
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = signToken({ userId: user.id, username: user.username, role: user.role });
|
||||
return res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req: Request, res: Response) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
router.post('/logout', requireAuth, (_req: Request, res: Response) => {
|
||||
// JWT is stateless; client just discards token
|
||||
res.json({ message: 'Logged out' });
|
||||
});
|
||||
|
||||
router.put('/password', requireAuth, async (req: Request, res: Response) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Current and new password required' });
|
||||
}
|
||||
|
||||
const user = await queryOne<{ id: number; password_hash: string }>(
|
||||
`SELECT id, password_hash FROM users WHERE id = $1`,
|
||||
[req.user!.userId]
|
||||
);
|
||||
|
||||
if (!user || !(await bcrypt.compare(currentPassword, user.password_hash))) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 12);
|
||||
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, req.user!.userId]);
|
||||
return res.json({ message: 'Password updated' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { BackupService } from '../services/BackupService';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const backupService = new BackupService();
|
||||
|
||||
// GET /api/backups
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const { deviceId } = req.query;
|
||||
let sql = `
|
||||
SELECT b.*, d.name as device_name
|
||||
FROM backups b JOIN devices d ON d.id = b.device_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: unknown[] = [];
|
||||
if (deviceId) {
|
||||
sql += ` AND b.device_id = $1`;
|
||||
params.push(deviceId);
|
||||
}
|
||||
sql += ' ORDER BY b.created_at DESC';
|
||||
|
||||
const backups = await query(sql, params);
|
||||
res.json(backups);
|
||||
});
|
||||
|
||||
// POST /api/backups
|
||||
router.post('/', requireWrite, async (req: Request, res: Response) => {
|
||||
const { deviceId, notes } = req.body;
|
||||
if (!deviceId) return res.status(400).json({ error: 'deviceId is required' });
|
||||
|
||||
const device = await queryOne<any>(
|
||||
`SELECT id, name, ip_address, ssh_port, ssh_username, ssh_password_encrypted,
|
||||
api_username, api_password_encrypted
|
||||
FROM devices WHERE id = $1`,
|
||||
[deviceId]
|
||||
);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
|
||||
try {
|
||||
const backupId = await backupService.createBackup(device, notes);
|
||||
const backup = await queryOne(`SELECT * FROM backups WHERE id = $1`, [backupId]);
|
||||
return res.status(201).json(backup);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: `Backup failed: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/backups/:id/download
|
||||
router.get('/:id/download', async (req: Request, res: Response) => {
|
||||
const backup = await queryOne<{ file_path: string; filename: string }>(
|
||||
`SELECT file_path, filename FROM backups WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!backup) return res.status(404).json({ error: 'Backup not found' });
|
||||
if (!fs.existsSync(backup.file_path)) {
|
||||
return res.status(404).json({ error: 'Backup file not found on disk' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${backup.filename}"`);
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
return res.sendFile(path.resolve(backup.file_path));
|
||||
});
|
||||
|
||||
// POST /api/backups/:id/restore
|
||||
router.post('/:id/restore', requireWrite, async (req: Request, res: Response) => {
|
||||
const backup = await queryOne(`SELECT id FROM backups WHERE id = $1`, [req.params.id]);
|
||||
if (!backup) return res.status(404).json({ error: 'Backup not found' });
|
||||
|
||||
try {
|
||||
await backupService.restoreBackup(parseInt(req.params.id));
|
||||
return res.json({ message: 'Restore initiated successfully' });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: `Restore failed: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/backups/:id
|
||||
router.delete('/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const backup = await queryOne(`SELECT id FROM backups WHERE id = $1`, [req.params.id]);
|
||||
if (!backup) return res.status(404).json({ error: 'Backup not found' });
|
||||
|
||||
await backupService.deleteBackup(parseInt(req.params.id));
|
||||
return res.json({ message: 'Backup deleted' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { readFile, writeFile, unlink } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CERTS_DIR = '/certs';
|
||||
const CERT_PATH = path.join(CERTS_DIR, 'server.crt');
|
||||
const KEY_PATH = path.join(CERTS_DIR, 'server.key');
|
||||
const RELOAD_SIGNAL = path.join(CERTS_DIR, '.reload');
|
||||
|
||||
function parseCertInfo(pem: string) {
|
||||
const cert = new crypto.X509Certificate(pem);
|
||||
const now = new Date();
|
||||
const validTo = new Date(cert.validTo);
|
||||
const daysLeft = Math.floor((validTo.getTime() - now.getTime()) / 86400000);
|
||||
const isSelfSigned = cert.subject === cert.issuer;
|
||||
|
||||
// Extract CN from subject string like "CN=Mikrotik Manager, O=Self-Signed"
|
||||
const cnMatch = cert.subject.match(/CN=([^,\n]+)/);
|
||||
const cn = cnMatch ? cnMatch[1].trim() : cert.subject;
|
||||
|
||||
const issuerCnMatch = cert.issuer.match(/CN=([^,\n]+)/);
|
||||
const issuerCn = issuerCnMatch ? issuerCnMatch[1].trim() : cert.issuer;
|
||||
|
||||
return {
|
||||
subject: cn,
|
||||
subject_full: cert.subject,
|
||||
issuer: issuerCn,
|
||||
issuer_full: cert.issuer,
|
||||
serial_number: cert.serialNumber,
|
||||
valid_from: cert.validFrom,
|
||||
valid_to: cert.validTo,
|
||||
days_remaining: daysLeft,
|
||||
is_self_signed: isSelfSigned,
|
||||
san: cert.subjectAltName || null,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/cert — current certificate info
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
if (!existsSync(CERT_PATH)) {
|
||||
return res.json({ exists: false });
|
||||
}
|
||||
try {
|
||||
const pem = await readFile(CERT_PATH, 'utf8');
|
||||
return res.json({ exists: true, ...parseCertInfo(pem) });
|
||||
} catch {
|
||||
return res.json({ exists: false });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cert/regenerate — generate a fresh self-signed certificate
|
||||
router.post('/regenerate', requireAdmin, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
await execFileAsync('openssl', [
|
||||
'req', '-x509', '-nodes', '-days', '3650',
|
||||
'-newkey', 'rsa:2048',
|
||||
'-keyout', KEY_PATH,
|
||||
'-out', CERT_PATH,
|
||||
'-subj', '/CN=Mikrotik Manager/O=Self-Signed/OU=Local/C=US',
|
||||
'-addext', 'subjectAltName=IP:127.0.0.1,DNS:localhost',
|
||||
], { timeout: 30000 });
|
||||
|
||||
// Signal nginx to reload
|
||||
await writeFile(RELOAD_SIGNAL, '');
|
||||
|
||||
const pem = await readFile(CERT_PATH, 'utf8');
|
||||
return res.json({ message: 'Self-signed certificate regenerated', ...parseCertInfo(pem) });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ error: `Failed to generate certificate: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cert/upload — install a custom certificate and private key
|
||||
router.post('/upload', requireAdmin, async (req: Request, res: Response) => {
|
||||
const { certificate, private_key } = req.body as { certificate?: string; private_key?: string };
|
||||
|
||||
if (!certificate || !private_key) {
|
||||
return res.status(400).json({ error: 'Both certificate and private_key are required' });
|
||||
}
|
||||
|
||||
// Validate PEM format
|
||||
if (!certificate.includes('-----BEGIN CERTIFICATE-----')) {
|
||||
return res.status(400).json({ error: 'certificate does not appear to be a valid PEM certificate' });
|
||||
}
|
||||
if (!private_key.includes('-----BEGIN')) {
|
||||
return res.status(400).json({ error: 'private_key does not appear to be a valid PEM key' });
|
||||
}
|
||||
|
||||
// Parse the certificate
|
||||
let certInfo;
|
||||
try {
|
||||
certInfo = parseCertInfo(certificate);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Failed to parse certificate — ensure it is valid PEM format' });
|
||||
}
|
||||
|
||||
// Verify the private key matches the certificate's public key
|
||||
try {
|
||||
const certObj = new crypto.X509Certificate(certificate);
|
||||
const privKey = crypto.createPrivateKey(private_key);
|
||||
const certPubKey = certObj.publicKey;
|
||||
|
||||
// Sign test data with private key, verify with cert's public key
|
||||
const sign = crypto.createSign('SHA256');
|
||||
sign.update('mikrotik-manager-cert-verify');
|
||||
const signature = sign.sign(privKey);
|
||||
|
||||
const verify = crypto.createVerify('SHA256');
|
||||
verify.update('mikrotik-manager-cert-verify');
|
||||
const matches = verify.verify(certPubKey, signature);
|
||||
|
||||
if (!matches) {
|
||||
return res.status(400).json({ error: 'Private key does not match the certificate' });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return res.status(400).json({ error: `Key/certificate validation failed: ${msg}` });
|
||||
}
|
||||
|
||||
// Write files atomically (write to temp, then rename)
|
||||
const tmpCert = CERT_PATH + '.tmp';
|
||||
const tmpKey = KEY_PATH + '.tmp';
|
||||
try {
|
||||
await writeFile(tmpCert, certificate.trim() + '\n', { mode: 0o644 });
|
||||
await writeFile(tmpKey, private_key.trim() + '\n', { mode: 0o600 });
|
||||
|
||||
// Rename into place
|
||||
const { rename } = await import('fs/promises');
|
||||
await rename(tmpCert, CERT_PATH);
|
||||
await rename(tmpKey, KEY_PATH);
|
||||
|
||||
// Signal nginx to reload
|
||||
await writeFile(RELOAD_SIGNAL, '');
|
||||
|
||||
return res.json({ message: 'Certificate installed successfully', ...certInfo });
|
||||
} catch (err) {
|
||||
// Clean up temp files if they exist
|
||||
await unlink(tmpCert).catch(() => {});
|
||||
await unlink(tmpKey).catch(() => {});
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ error: `Failed to write certificate: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,322 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as dgram from 'dgram';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
|
||||
import { PollerService } from '../services/PollerService';
|
||||
import { getQueryApi, bucket } from '../config/influxdb';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
let pollerService: PollerService | null = null;
|
||||
export function setPollerService(p: PollerService): void { pollerService = p; }
|
||||
|
||||
// GET /api/clients
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const { deviceId, active, search, client_type, limit = '100', offset = '0' } = req.query;
|
||||
|
||||
// Build WHERE clause shared by both queries
|
||||
const filters: string[] = [];
|
||||
const filterParams: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (deviceId) {
|
||||
filters.push(`c.device_id = $${idx++}`);
|
||||
filterParams.push(deviceId);
|
||||
}
|
||||
if (active === 'true') {
|
||||
filters.push(`c.active = TRUE`);
|
||||
}
|
||||
if (client_type) {
|
||||
filters.push(`c.client_type = $${idx++}`);
|
||||
filterParams.push(client_type);
|
||||
}
|
||||
if (search) {
|
||||
filters.push(`(c.mac_address ILIKE $${idx} OR c.custom_name ILIKE $${idx} OR c.hostname ILIKE $${idx} OR c.ip_address ILIKE $${idx} OR c.vendor ILIKE $${idx})`);
|
||||
filterParams.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
|
||||
// Deduplicate by MAC address across devices: prefer active rows, then most recently seen.
|
||||
// The inner DISTINCT ON picks the best row per MAC; the outer query applies final sort + pagination.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (c.mac_address)
|
||||
c.*, d.name as device_name,
|
||||
wi.ssid as ssid
|
||||
FROM clients c
|
||||
JOIN devices d ON d.id = c.device_id
|
||||
LEFT JOIN wireless_interfaces wi
|
||||
ON wi.device_id = c.device_id AND wi.name = c.interface_name
|
||||
${where}
|
||||
ORDER BY c.mac_address, (c.client_type = 'wireless') DESC, c.active DESC, c.last_seen DESC NULLS LAST
|
||||
) deduped
|
||||
ORDER BY last_seen DESC NULLS LAST
|
||||
LIMIT $${idx++} OFFSET $${idx++}
|
||||
`;
|
||||
const clients = await query(sql, [...filterParams, parseInt(String(limit), 10), parseInt(String(offset), 10)]);
|
||||
|
||||
const countResult = await query<{ total: string }>(
|
||||
`SELECT COUNT(DISTINCT c.mac_address) as total FROM clients c ${where}`,
|
||||
filterParams
|
||||
);
|
||||
|
||||
res.json({
|
||||
clients,
|
||||
total: parseInt(countResult[0]?.total || '0', 10),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/clients/:mac — detail view with SSID, VLAN name, device type, upstream topology
|
||||
router.get('/:mac', async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
|
||||
// Order: wireless records before wired (AP knows the client best), then active,
|
||||
// then most recently seen. Use LATERAL for topology so we never multiply rows.
|
||||
const rows = await query<Record<string, unknown>>(
|
||||
`SELECT c.*, d.name as device_name, d.device_type,
|
||||
wi.ssid as ssid,
|
||||
v.name as vlan_name,
|
||||
topo.upstream_device_id,
|
||||
topo.upstream_interface,
|
||||
topo.upstream_device_name,
|
||||
topo.upstream_device_type,
|
||||
topo.upstream_device_ip
|
||||
FROM clients c
|
||||
JOIN devices d ON d.id = c.device_id
|
||||
LEFT JOIN wireless_interfaces wi
|
||||
ON wi.device_id = c.device_id AND wi.name = c.interface_name
|
||||
LEFT JOIN vlans v
|
||||
ON v.device_id = c.device_id AND v.vlan_id = c.vlan_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT tl.to_device_id AS upstream_device_id,
|
||||
tl.to_interface AS upstream_interface,
|
||||
ud.name AS upstream_device_name,
|
||||
ud.device_type AS upstream_device_type,
|
||||
ud.ip_address AS upstream_device_ip
|
||||
FROM topology_links tl
|
||||
JOIN devices ud ON ud.id = tl.to_device_id
|
||||
WHERE tl.from_device_id = c.device_id
|
||||
LIMIT 1
|
||||
) topo ON TRUE
|
||||
WHERE LOWER(c.mac_address) = $1
|
||||
ORDER BY (c.client_type = 'wireless') DESC, c.active DESC, c.last_seen DESC NULLS LAST
|
||||
LIMIT 1`,
|
||||
[mac]
|
||||
);
|
||||
|
||||
if (!rows.length) return res.status(404).json({ error: 'Client not found' });
|
||||
return res.json(rows[0]);
|
||||
});
|
||||
|
||||
// GET /api/clients/:mac/presence?range=2h|24h|7d — connectivity timeline from InfluxDB
|
||||
router.get('/:mac/presence', async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
const rangeRaw = String(req.query.range || '24h');
|
||||
const allowedRanges: Record<string, string> = { '2h': '2h', '24h': '24h', '7d': '7d' };
|
||||
const range = allowedRanges[rangeRaw] || '24h';
|
||||
|
||||
// Window size: 5m for 2h/24h, 30m for 7d
|
||||
const window = range === '7d' ? '30m' : '5m';
|
||||
|
||||
const queryApi = getQueryApi();
|
||||
// Group by mac_address only (drop device_id) so that all devices reporting the
|
||||
// same client are merged into one series. fn:max means online=1 if any device
|
||||
// saw the client in that window; createEmpty+fill gives offline=0 for gaps.
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_presence")
|
||||
|> filter(fn: (r) => r._field == "online")
|
||||
|> filter(fn: (r) => r.mac_address == "${mac}")
|
||||
|> group(columns: ["_measurement", "_field", "mac_address"])
|
||||
|> aggregateWindow(every: ${window}, fn: max, createEmpty: true)
|
||||
|> fill(value: 0)
|
||||
|> yield(name: "presence")
|
||||
`;
|
||||
|
||||
const points: { time: string; online: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
points.push({ time, online: value != null ? Math.round(value) : 0 });
|
||||
});
|
||||
} catch {
|
||||
// InfluxDB not available or no data yet
|
||||
}
|
||||
|
||||
return res.json(points);
|
||||
});
|
||||
|
||||
// GET /api/clients/:mac/traffic?range=2h|24h|7d — wireless tx/rx rate graph
|
||||
router.get('/:mac/traffic', async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
const rangeRaw = String(req.query.range || '24h');
|
||||
const allowedRanges: Record<string, string> = { '2h': '2h', '24h': '24h', '7d': '7d' };
|
||||
const range = allowedRanges[rangeRaw] || '24h';
|
||||
|
||||
const window = range === '7d' ? '30m' : '5m';
|
||||
|
||||
const queryApi = getQueryApi();
|
||||
// Simple query: get the last cumulative byte counter value per time window.
|
||||
// Non-negative differences are computed in TypeScript below — avoids the
|
||||
// fragile non_negative_difference+pivot combination in Flux.
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_presence")
|
||||
|> filter(fn: (r) => r._field == "tx_bytes" or r._field == "rx_bytes")
|
||||
|> filter(fn: (r) => r.mac_address == "${mac}")
|
||||
|> group(columns: ["_measurement", "_field", "mac_address"])
|
||||
|> aggregateWindow(every: ${window}, fn: last, createEmpty: false)
|
||||
|> yield(name: "traffic_raw")
|
||||
`;
|
||||
|
||||
const raw: { time: string; field: string; value: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const field = tableMeta.get(row, '_field') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
if (time && field && value != null) raw.push({ time, field, value });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[clients/traffic] Flux error for ${mac}:`, err);
|
||||
}
|
||||
|
||||
// Separate tx and rx series, sort by time, then compute non-negative deltas
|
||||
const txSeries = raw.filter(p => p.field === 'tx_bytes').sort((a, b) => a.time.localeCompare(b.time));
|
||||
const rxSeries = raw.filter(p => p.field === 'rx_bytes').sort((a, b) => a.time.localeCompare(b.time));
|
||||
|
||||
const txDeltas = new Map<string, number>();
|
||||
for (let i = 1; i < txSeries.length; i++) {
|
||||
const diff = txSeries[i].value - txSeries[i - 1].value;
|
||||
if (diff >= 0) txDeltas.set(txSeries[i].time, diff);
|
||||
}
|
||||
|
||||
const rxDeltas = new Map<string, number>();
|
||||
for (let i = 1; i < rxSeries.length; i++) {
|
||||
const diff = rxSeries[i].value - rxSeries[i - 1].value;
|
||||
if (diff >= 0) rxDeltas.set(rxSeries[i].time, diff);
|
||||
}
|
||||
|
||||
const allTimes = new Set([...txDeltas.keys(), ...rxDeltas.keys()]);
|
||||
const points = [...allTimes].sort().map(time => ({
|
||||
time,
|
||||
tx_bytes: txDeltas.get(time) ?? 0,
|
||||
rx_bytes: rxDeltas.get(time) ?? 0,
|
||||
}));
|
||||
|
||||
return res.json(points);
|
||||
});
|
||||
|
||||
// GET /api/clients/:mac/signal?range=2h|24h|7d — signal strength over time
|
||||
router.get('/:mac/signal', async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
const rangeRaw = String(req.query.range || '24h');
|
||||
const allowedRanges: Record<string, string> = { '2h': '2h', '24h': '24h', '7d': '7d' };
|
||||
const range = allowedRanges[rangeRaw] || '24h';
|
||||
|
||||
const window = range === '7d' ? '30m' : '5m';
|
||||
|
||||
const queryApi = getQueryApi();
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_presence")
|
||||
|> filter(fn: (r) => r._field == "signal_strength")
|
||||
|> filter(fn: (r) => r.mac_address == "${mac}")
|
||||
|> group(columns: ["_measurement", "_field", "mac_address"])
|
||||
|> aggregateWindow(every: ${window}, fn: mean, createEmpty: false)
|
||||
|> yield(name: "signal")
|
||||
`;
|
||||
|
||||
const points: { time: string; signal_strength: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
if (time && value != null) points.push({ time, signal_strength: Math.round(value) });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[clients/signal] Flux error for ${mac}:`, err);
|
||||
}
|
||||
|
||||
return res.json(points);
|
||||
});
|
||||
|
||||
// POST /api/clients/:mac/wol — send Wake-on-LAN magic packet
|
||||
router.post('/:mac/wol', requireWrite, async (req: Request, res: Response) => {
|
||||
const mac = req.params.mac.replace(/[^0-9a-fA-F:.-]/g, '');
|
||||
const hexMac = mac.replace(/[:\-\.]/g, '');
|
||||
if (hexMac.length !== 12) {
|
||||
return res.status(400).json({ error: 'Invalid MAC address' });
|
||||
}
|
||||
|
||||
// Build magic packet: 6 bytes of 0xFF + MAC repeated 16 times
|
||||
const macBytes = Buffer.from(hexMac, 'hex');
|
||||
const magic = Buffer.alloc(102);
|
||||
magic.fill(0xff, 0, 6);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
macBytes.copy(magic, 6 + i * 6);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const socket = dgram.createSocket('udp4');
|
||||
socket.once('error', (err) => {
|
||||
socket.close();
|
||||
res.status(500).json({ error: `WoL send failed: ${err.message}` });
|
||||
resolve();
|
||||
});
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
socket.send(magic, 0, magic.length, 9, '255.255.255.255', (err) => {
|
||||
socket.close();
|
||||
if (err) {
|
||||
res.status(500).json({ error: `WoL send failed: ${err.message}` });
|
||||
} else {
|
||||
res.json({ success: true, message: `Magic packet sent to ${mac}` });
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/clients/:mac/hostname (writes to custom_name — user-set names persist across collector cycles)
|
||||
router.put('/:mac/hostname', requireWrite, async (req: Request, res: Response) => {
|
||||
const { hostname } = req.body as { hostname?: string };
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
|
||||
const result = await query(
|
||||
`UPDATE clients SET custom_name = $1 WHERE LOWER(mac_address) = $2 RETURNING *`,
|
||||
[hostname || null, mac]
|
||||
);
|
||||
if (!result.length) return res.status(404).json({ error: 'Client not found' });
|
||||
return res.json(result[0]);
|
||||
});
|
||||
|
||||
// PUT /api/clients/:mac/notes — update the comment/notes field
|
||||
router.put('/:mac/notes', requireWrite, async (req: Request, res: Response) => {
|
||||
const { notes } = req.body as { notes?: string };
|
||||
const mac = req.params.mac.toLowerCase();
|
||||
|
||||
const result = await query(
|
||||
`UPDATE clients SET comment = $1 WHERE LOWER(mac_address) = $2 RETURNING *`,
|
||||
[notes ?? null, mac]
|
||||
);
|
||||
if (!result.length) return res.status(404).json({ error: 'Client not found' });
|
||||
return res.json(result[0]);
|
||||
});
|
||||
|
||||
// POST /api/clients/purge — immediately delete inactive clients older than retention period
|
||||
router.post('/purge', requireAdmin, async (_req: Request, res: Response) => {
|
||||
if (!pollerService) return res.status(503).json({ error: 'Poller not available' });
|
||||
const count = await pollerService.pruneStaleClientsNow();
|
||||
return res.json({ message: `Purged ${count} stale client record(s)`, count });
|
||||
});
|
||||
|
||||
export default router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/events
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const {
|
||||
deviceId,
|
||||
severity,
|
||||
topic,
|
||||
search,
|
||||
since,
|
||||
limit = '200',
|
||||
offset = '0',
|
||||
} = req.query;
|
||||
|
||||
const filters: string[] = ['1=1'];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (deviceId) {
|
||||
filters.push(`e.device_id = $${idx++}`);
|
||||
params.push(deviceId);
|
||||
}
|
||||
if (severity) {
|
||||
// Accept comma-separated list e.g. "error,warning"
|
||||
// 'error' also includes 'critical' rows
|
||||
const requested = String(severity).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const expanded = new Set<string>();
|
||||
for (const s of requested) {
|
||||
expanded.add(s);
|
||||
if (s === 'error') expanded.add('critical');
|
||||
}
|
||||
const placeholders = Array.from(expanded).map(() => `$${idx++}`).join(',');
|
||||
filters.push(`e.severity IN (${placeholders})`);
|
||||
params.push(...Array.from(expanded));
|
||||
}
|
||||
if (topic) {
|
||||
filters.push(`e.topic ILIKE $${idx++}`);
|
||||
params.push(`%${topic}%`);
|
||||
}
|
||||
if (since) {
|
||||
filters.push(`e.event_time >= $${idx++}`);
|
||||
params.push(since);
|
||||
}
|
||||
if (search) {
|
||||
filters.push(`(e.message ILIKE $${idx} OR e.topic ILIKE $${idx})`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = filters.join(' AND ');
|
||||
|
||||
const [events, totalResult, criticalCount] = await Promise.all([
|
||||
query(
|
||||
`SELECT e.*, d.name as device_name
|
||||
FROM events e
|
||||
LEFT JOIN devices d ON d.id = e.device_id
|
||||
WHERE ${where}
|
||||
ORDER BY e.event_time DESC
|
||||
LIMIT $${idx++} OFFSET $${idx++}`,
|
||||
[...params, parseInt(String(limit), 10), parseInt(String(offset), 10)]
|
||||
),
|
||||
query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM events e WHERE ${where}`,
|
||||
params
|
||||
),
|
||||
query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM events WHERE severity IN ('error','critical') AND event_time > NOW() - INTERVAL '24 hours'`
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
events,
|
||||
total: parseInt(totalResult[0]?.count || '0', 10),
|
||||
criticalCount: parseInt(criticalCount[0]?.count || '0', 10),
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/events (clear events for a device or all)
|
||||
router.delete('/', requireWrite, async (req: Request, res: Response) => {
|
||||
const { deviceId } = req.query;
|
||||
if (deviceId) {
|
||||
await query(`DELETE FROM events WHERE device_id = $1`, [deviceId]);
|
||||
} else {
|
||||
await query(`DELETE FROM events`);
|
||||
}
|
||||
res.json({ message: 'Events cleared' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getQueryApi, bucket } from '../config/influxdb';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
function rangeToFlux(range: string): string {
|
||||
const allowed = ['1h', '3h', '6h', '12h', '24h', '7d', '30d'];
|
||||
return allowed.includes(range) ? range : '24h';
|
||||
}
|
||||
|
||||
// GET /api/metrics/clients-over-time?range=24h
|
||||
router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '24h'));
|
||||
const queryApi = getQueryApi();
|
||||
|
||||
// Use the global deduplicated metric (_global tag) written by DeviceCollector.
|
||||
// This avoids double-counting clients seen by multiple devices simultaneously.
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_counts")
|
||||
|> filter(fn: (r) => r._field == "total_clients")
|
||||
|> filter(fn: (r) => r.device_id == "_global")
|
||||
|> aggregateWindow(every: 5m, fn: last, createEmpty: false)
|
||||
|> yield(name: "clients_over_time")
|
||||
`;
|
||||
|
||||
const rawPoints: { time: string; value: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
rawPoints.push({ time, value: Math.round(value) });
|
||||
});
|
||||
} catch {
|
||||
// InfluxDB might not have data yet
|
||||
}
|
||||
|
||||
// If no global metric exists yet (first run or old data), fall back to max across devices.
|
||||
// max() is better than sum() since the device with the most clients is the gateway that
|
||||
// sees everyone — summing would double-count clients visible from multiple devices.
|
||||
if (rawPoints.length === 0) {
|
||||
const fallbackQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_counts")
|
||||
|> filter(fn: (r) => r._field == "total_clients")
|
||||
|> filter(fn: (r) => r.device_id != "_global")
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
|> group()
|
||||
|> aggregateWindow(every: 5m, fn: max, createEmpty: false)
|
||||
|> yield(name: "clients_over_time_fallback")
|
||||
`;
|
||||
try {
|
||||
await queryApi.collectRows(fallbackQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
rawPoints.push({ time, value: Math.round(value) });
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
}
|
||||
|
||||
const points = rawPoints.sort((a, b) => a.time.localeCompare(b.time));
|
||||
res.json(points);
|
||||
});
|
||||
|
||||
// GET /api/metrics/top-clients?limit=10&range=24h
|
||||
router.get('/top-clients', async (req: Request, res: Response) => {
|
||||
const limit = Math.min(parseInt(String(req.query.limit || '10'), 10), 50);
|
||||
|
||||
// Use postgres data (most recent client data)
|
||||
const clients = await query(
|
||||
`SELECT c.mac_address, c.hostname, c.ip_address, c.interface_name,
|
||||
c.tx_bytes + c.rx_bytes as total_bytes,
|
||||
c.tx_bytes, c.rx_bytes, c.client_type, d.name as device_name
|
||||
FROM clients c JOIN devices d ON d.id = c.device_id
|
||||
WHERE c.active = TRUE AND (c.tx_bytes + c.rx_bytes) > 0
|
||||
ORDER BY total_bytes DESC LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
res.json(clients);
|
||||
});
|
||||
|
||||
// GET /api/metrics/interface/:deviceId/:interface?range=1h
|
||||
router.get('/interface/:deviceId/:interface', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '1h'));
|
||||
const queryApi = getQueryApi();
|
||||
const deviceId = req.params.deviceId;
|
||||
const iface = req.params.interface;
|
||||
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "interface_traffic")
|
||||
|> filter(fn: (r) => r.device_id == "${deviceId}")
|
||||
|> filter(fn: (r) => r.interface == "${iface}")
|
||||
|> filter(fn: (r) => r._field == "rx_bytes" or r._field == "tx_bytes")
|
||||
|> aggregateWindow(every: 1m, fn: last, createEmpty: false)
|
||||
|> derivative(unit: 1s, nonNegative: true)
|
||||
|> yield(name: "traffic")
|
||||
`;
|
||||
|
||||
const points: Record<string, unknown>[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
points.push({
|
||||
time: tableMeta.get(row, '_time'),
|
||||
field: tableMeta.get(row, '_field'),
|
||||
value: tableMeta.get(row, '_value'),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
// Pivot rx/tx into single objects per timestamp
|
||||
const pivoted: Record<string, { time: string; rx: number; tx: number }> = {};
|
||||
for (const p of points) {
|
||||
const t = p['time'] as string;
|
||||
if (!pivoted[t]) pivoted[t] = { time: t, rx: 0, tx: 0 };
|
||||
if (p['field'] === 'rx_bytes') pivoted[t].rx = Number(p['value']) || 0;
|
||||
if (p['field'] === 'tx_bytes') pivoted[t].tx = Number(p['value']) || 0;
|
||||
}
|
||||
|
||||
res.json(Object.values(pivoted).sort((a, b) => a.time.localeCompare(b.time)));
|
||||
});
|
||||
|
||||
// GET /api/metrics/interface/:deviceId/:interface/packets?range=1h
|
||||
router.get('/interface/:deviceId/:interface/packets', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '1h'));
|
||||
const queryApi = getQueryApi();
|
||||
const deviceId = req.params.deviceId;
|
||||
const iface = req.params.interface;
|
||||
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "interface_traffic")
|
||||
|> filter(fn: (r) => r.device_id == "${deviceId}")
|
||||
|> filter(fn: (r) => r.interface == "${iface}")
|
||||
|> filter(fn: (r) => r._field == "rx_packets" or r._field == "tx_packets")
|
||||
|> aggregateWindow(every: 1m, fn: last, createEmpty: false)
|
||||
|> derivative(unit: 1s, nonNegative: true)
|
||||
|> yield(name: "packets")
|
||||
`;
|
||||
|
||||
const points: Record<string, unknown>[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
points.push({
|
||||
time: tableMeta.get(row, '_time'),
|
||||
field: tableMeta.get(row, '_field'),
|
||||
value: tableMeta.get(row, '_value'),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
const pivoted: Record<string, { time: string; rx: number; tx: number }> = {};
|
||||
for (const p of points) {
|
||||
const t = p['time'] as string;
|
||||
if (!pivoted[t]) pivoted[t] = { time: t, rx: 0, tx: 0 };
|
||||
if (p['field'] === 'rx_packets') pivoted[t].rx = Number(p['value']) || 0;
|
||||
if (p['field'] === 'tx_packets') pivoted[t].tx = Number(p['value']) || 0;
|
||||
}
|
||||
|
||||
res.json(Object.values(pivoted).sort((a, b) => a.time.localeCompare(b.time)));
|
||||
});
|
||||
|
||||
// GET /api/metrics/device/:deviceId/resources?range=24h
|
||||
router.get('/device/:deviceId/resources', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '24h'));
|
||||
const queryApi = getQueryApi();
|
||||
const deviceId = req.params.deviceId;
|
||||
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "device_resources")
|
||||
|> filter(fn: (r) => r.device_id == "${deviceId}")
|
||||
|> filter(fn: (r) => r._field == "cpu_load" or r._field == "memory_used" or r._field == "memory_total")
|
||||
|> aggregateWindow(every: 5m, fn: mean, createEmpty: false)
|
||||
|> yield(name: "resources")
|
||||
`;
|
||||
|
||||
const points: Record<string, unknown>[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
points.push({
|
||||
time: tableMeta.get(row, '_time'),
|
||||
field: tableMeta.get(row, '_field'),
|
||||
value: tableMeta.get(row, '_value'),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
const pivoted: Record<string, Record<string, unknown>> = {};
|
||||
for (const p of points) {
|
||||
const t = p['time'] as string;
|
||||
if (!pivoted[t]) pivoted[t] = { time: t };
|
||||
pivoted[t][p['field'] as string] = Number(p['value']) || 0;
|
||||
}
|
||||
|
||||
res.json(Object.values(pivoted).sort((a, b) => String(a['time']).localeCompare(String(b['time']))));
|
||||
});
|
||||
|
||||
// GET /api/metrics/summary - dashboard summary stats
|
||||
router.get('/summary', async (_req: Request, res: Response) => {
|
||||
const [deviceStats, clientStats, alertStats] = await Promise.all([
|
||||
query<{ total: string; online: string; offline: string }>(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status='online') as online,
|
||||
COUNT(*) FILTER (WHERE status='offline') as offline
|
||||
FROM devices`
|
||||
),
|
||||
query<{ total: string; active: string }>(
|
||||
`SELECT COUNT(DISTINCT mac_address) as total,
|
||||
COUNT(DISTINCT mac_address) FILTER (WHERE active=TRUE) as active
|
||||
FROM clients`
|
||||
),
|
||||
query<{ critical: string; warning: string }>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE severity='error') as critical,
|
||||
COUNT(*) FILTER (WHERE severity='warning') as warning
|
||||
FROM events WHERE event_time > NOW() - INTERVAL '24 hours'`
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
devices: {
|
||||
total: parseInt(deviceStats[0]?.total || '0', 10),
|
||||
online: parseInt(deviceStats[0]?.online || '0', 10),
|
||||
offline: parseInt(deviceStats[0]?.offline || '0', 10),
|
||||
},
|
||||
clients: {
|
||||
total: parseInt(clientStats[0]?.total || '0', 10),
|
||||
active: parseInt(clientStats[0]?.active || '0', 10),
|
||||
},
|
||||
alerts: {
|
||||
critical: parseInt(alertStats[0]?.critical || '0', 10),
|
||||
warning: parseInt(alertStats[0]?.warning || '0', 10),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function withDevice<T>(
|
||||
deviceId: number,
|
||||
res: Response,
|
||||
fn: (collector: DeviceCollector, device: DeviceRow) => Promise<T>
|
||||
): Promise<T | void> {
|
||||
const rows = await query<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [deviceId]);
|
||||
const device = rows[0];
|
||||
if (!device) {
|
||||
res.status(404).json({ error: 'Device not found' });
|
||||
return;
|
||||
}
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
return await fn(collector, device);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function deviceIdParam(req: Request, res: Response): number | null {
|
||||
const id = parseInt(req.query.deviceId as string);
|
||||
if (!id) { res.status(400).json({ error: 'deviceId query param is required' }); return null; }
|
||||
return id;
|
||||
}
|
||||
|
||||
// ─── Overview (all online devices, all services) ──────────────────────────────
|
||||
|
||||
router.get('/overview', async (_req: Request, res: Response) => {
|
||||
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online' ORDER BY name`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
devices.map(async (device: DeviceRow) => {
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
const [dhcpV4, dhcpV6, dns, ntp, wg] = await Promise.allSettled([
|
||||
collector.getDhcpServers('ipv4'),
|
||||
collector.getDhcpServers('ipv6'),
|
||||
collector.getDnsSettings(),
|
||||
collector.getNtpSettings(),
|
||||
collector.getWireGuardInterfaces(),
|
||||
]);
|
||||
|
||||
const v4Servers = dhcpV4.status === 'fulfilled' ? dhcpV4.value : [];
|
||||
const v6Servers = dhcpV6.status === 'fulfilled' ? dhcpV6.value : [];
|
||||
const dnsRow = dns.status === 'fulfilled' ? dns.value : null;
|
||||
const ntpRow = ntp.status === 'fulfilled' ? ntp.value : null;
|
||||
const wgIfaces = wg.status === 'fulfilled' ? wg.value : [];
|
||||
|
||||
return {
|
||||
id: device.id, name: device.name, ip_address: device.ip_address,
|
||||
dhcp_v4: { total: v4Servers.length, enabled: v4Servers.filter(s => s['disabled'] !== 'true').length },
|
||||
dhcp_v6: { total: v6Servers.length, enabled: v6Servers.filter(s => s['disabled'] !== 'true').length },
|
||||
dns: dnsRow ? { allow_remote: dnsRow['allow-remote-requests'] === 'yes', servers: dnsRow['servers'] || '' } : null,
|
||||
ntp: ntpRow ? { server_enabled: ntpRow.server['enabled'] === 'yes', client_enabled: ntpRow.client['enabled'] === 'yes' } : null,
|
||||
wireguard: { total: wgIfaces.length, running: wgIfaces.filter(i => i['running'] === 'true').length },
|
||||
};
|
||||
} catch (err) {
|
||||
return { id: device.id, name: device.name, ip_address: device.ip_address,
|
||||
dhcp_v4: null, dhcp_v6: null, dns: null, ntp: null, wireguard: null,
|
||||
error: (err as Error).message };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
res.json(results.map(r => r.status === 'fulfilled' ? r.value : { error: (r.reason as Error).message }));
|
||||
});
|
||||
|
||||
// ─── DHCP ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/network-services/dhcp?deviceId=X
|
||||
router.get('/dhcp', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const [v4, v6, pv4, pv6] = await Promise.allSettled([
|
||||
collector.getDhcpServers('ipv4'),
|
||||
collector.getDhcpServers('ipv6'),
|
||||
collector.getDhcpPools('ipv4'),
|
||||
collector.getDhcpPools('ipv6'),
|
||||
]);
|
||||
res.json({
|
||||
ipv4: v4.status === 'fulfilled' ? v4.value : [],
|
||||
ipv6: v6.status === 'fulfilled' ? v6.value : [],
|
||||
pools_v4: pv4.status === 'fulfilled' ? pv4.value : [],
|
||||
pools_v6: pv6.status === 'fulfilled' ? pv6.value : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dhcp/server?deviceId=X — create DHCP server
|
||||
router.post('/dhcp/server', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { protocol, ...params } = req.body;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol must be "ipv4" or "ipv6"' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addDhcpServer(params, protocol as 'ipv4' | 'ipv6');
|
||||
const updated = await collector.getDhcpServers(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/dhcp/server/:id?deviceId=X — update DHCP server
|
||||
router.put('/dhcp/server/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { protocol, ...params } = req.body;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.updateDhcpServer(req.params.id, params, protocol as 'ipv4' | 'ipv6');
|
||||
const updated = await collector.getDhcpServers(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/dhcp/server/:id?deviceId=X&protocol=X — delete DHCP server
|
||||
router.delete('/dhcp/server/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = req.query.protocol as string;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol query param required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeDhcpServer(req.params.id, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/dhcp/server — toggle enable/disable (legacy compat)
|
||||
router.put('/dhcp/server', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { serverId, disabled, protocol } = req.body;
|
||||
if (!serverId) return res.status(400).json({ error: 'serverId is required' });
|
||||
if (typeof disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' });
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.setDhcpServerDisabled(serverId, disabled, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/network-services/dhcp/pools?deviceId=X&protocol=X
|
||||
router.get('/dhcp/pools', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = req.query.protocol as string;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol query param required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const pools = await collector.getDhcpPools(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(pools);
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dhcp/pool?deviceId=X — add pool
|
||||
router.post('/dhcp/pool', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { protocol, ...params } = req.body;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addDhcpPool(params, protocol as 'ipv4' | 'ipv6');
|
||||
const updated = await collector.getDhcpPools(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/dhcp/pool/:id?deviceId=X&protocol=X — remove pool
|
||||
router.delete('/dhcp/pool/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = req.query.protocol as string;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol query param required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeDhcpPool(req.params.id, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/network-services/dhcp/leases?deviceId=X&protocol=X
|
||||
router.get('/dhcp/leases', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = (req.query.protocol as string) || 'ipv4';
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol must be ipv4 or ipv6' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const leases = await collector.getDhcpLeases(protocol as 'ipv4' | 'ipv6');
|
||||
res.json(leases);
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dhcp/static-lease?deviceId=X — add static lease
|
||||
router.post('/dhcp/static-lease', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { protocol, ...params } = req.body;
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addStaticDhcpLease(params, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/dhcp/static-lease/:id?deviceId=X&protocol=X
|
||||
router.delete('/dhcp/static-lease/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const protocol = (req.query.protocol as string) || 'ipv4';
|
||||
if (protocol !== 'ipv4' && protocol !== 'ipv6') return res.status(400).json({ error: 'protocol required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeStaticDhcpLease(req.params.id, protocol as 'ipv4' | 'ipv6');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DNS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/network-services/dns?deviceId=X
|
||||
router.get('/dns', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const [settings, statics] = await Promise.allSettled([
|
||||
collector.getDnsSettings(),
|
||||
collector.getDnsStaticEntries(),
|
||||
]);
|
||||
res.json({
|
||||
settings: settings.status === 'fulfilled' ? settings.value : {},
|
||||
statics: statics.status === 'fulfilled' ? statics.value : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/dns?deviceId=X
|
||||
router.put('/dns', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.setDnsSettings(req.body);
|
||||
const updated = await collector.getDnsSettings();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dns/flush?deviceId=X
|
||||
router.post('/dns/flush', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.flushDnsCache();
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/network-services/dns/static?deviceId=X
|
||||
router.get('/dns/static', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const entries = await collector.getDnsStaticEntries();
|
||||
res.json(entries);
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/dns/static?deviceId=X
|
||||
router.post('/dns/static', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addDnsStaticEntry(req.body);
|
||||
const updated = await collector.getDnsStaticEntries();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/dns/static/:id?deviceId=X
|
||||
router.put('/dns/static/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.updateDnsStaticEntry(req.params.id, req.body);
|
||||
const updated = await collector.getDnsStaticEntries();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/dns/static/:id?deviceId=X
|
||||
router.delete('/dns/static/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeDnsStaticEntry(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── NTP ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/network-services/ntp?deviceId=X
|
||||
router.get('/ntp', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const settings = await collector.getNtpSettings();
|
||||
res.json(settings);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/ntp?deviceId=X
|
||||
router.put('/ntp', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.setNtpSettings(req.body);
|
||||
const updated = await collector.getNtpSettings();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WireGuard ────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/network-services/wireguard?deviceId=X
|
||||
router.get('/wireguard', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const [interfaces, peers] = await Promise.allSettled([
|
||||
collector.getWireGuardInterfaces(),
|
||||
collector.getWireGuardPeers(),
|
||||
]);
|
||||
res.json({
|
||||
interfaces: interfaces.status === 'fulfilled' ? interfaces.value : [],
|
||||
peers: peers.status === 'fulfilled' ? peers.value : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/wireguard?deviceId=X — create interface
|
||||
router.post('/wireguard', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const interfaces = await collector.addWireGuardInterface(req.body);
|
||||
res.json(interfaces);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/wireguard/toggle — enable/disable (must be before /:id)
|
||||
router.put('/wireguard/toggle', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { interfaceId, disabled } = req.body;
|
||||
if (!interfaceId) return res.status(400).json({ error: 'interfaceId is required' });
|
||||
if (typeof disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' });
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.setWireGuardInterfaceDisabled(interfaceId, disabled);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/wireguard/:id?deviceId=X — update interface
|
||||
router.put('/wireguard/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.updateWireGuardInterface(req.params.id, req.body);
|
||||
const updated = await collector.getWireGuardInterfaces();
|
||||
res.json(updated);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/wireguard/:id?deviceId=X — delete interface
|
||||
router.delete('/wireguard/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeWireGuardInterface(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/network-services/wireguard/peer?deviceId=X — add peer
|
||||
router.post('/wireguard/peer', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.addWireGuardPeer(req.body);
|
||||
const peers = await collector.getWireGuardPeers();
|
||||
res.json(peers);
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/wireguard/peer/:id?deviceId=X — update peer
|
||||
router.put('/wireguard/peer/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.updateWireGuardPeer(req.params.id, req.body);
|
||||
const peers = await collector.getWireGuardPeers();
|
||||
res.json(peers);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /api/network-services/wireguard/peer/:id?deviceId=X — delete peer
|
||||
router.delete('/wireguard/peer/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
await collector.removeWireGuardPeer(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/routers/lldp — LLDP enabled/disabled status per online router
|
||||
router.get('/lldp', async (_req: Request, res: Response) => {
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
routers.map(async (r: DeviceRow) => {
|
||||
const collector = new DeviceCollector(r);
|
||||
try {
|
||||
await collector.connect();
|
||||
const lldp = await collector.getLldpEnabled();
|
||||
return { id: r.id, name: r.name, ip_address: r.ip_address, ...lldp };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: routers[i].id,
|
||||
name: routers[i].name,
|
||||
ip_address: routers[i].ip_address,
|
||||
enabled: null as boolean | null,
|
||||
protocol: null as string | null,
|
||||
error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(statuses);
|
||||
});
|
||||
|
||||
// PUT /api/routers/lldp — enable or disable LLDP on all online routers
|
||||
router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: '"enabled" (boolean) is required' });
|
||||
}
|
||||
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
routers.map(async (r: DeviceRow) => {
|
||||
const collector = new DeviceCollector(r);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setLldpEnabled(enabled);
|
||||
return { id: r.id, name: r.name, success: true };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: routers[i].id,
|
||||
name: routers[i].name,
|
||||
success: false,
|
||||
error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({
|
||||
applied: statuses.filter(s => s.success).length,
|
||||
total: routers.length,
|
||||
results: statuses,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/routers/snmp — SNMP config/status per online router
|
||||
router.get('/snmp', async (_req: Request, res: Response) => {
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
routers.map(async (r: DeviceRow) => {
|
||||
const collector = new DeviceCollector(r);
|
||||
try {
|
||||
await collector.connect();
|
||||
const snmp = await collector.getSnmpConfig();
|
||||
return { id: r.id, name: r.name, ip_address: r.ip_address, ...snmp };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: routers[i].id, name: routers[i].name, ip_address: routers[i].ip_address,
|
||||
enabled: null as boolean | null, error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
res.json(statuses);
|
||||
});
|
||||
|
||||
// PUT /api/routers/snmp — apply SNMP config to all online routers
|
||||
router.put('/snmp', requireWrite, async (req: Request, res: Response) => {
|
||||
const config = req.body as {
|
||||
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
auth_protocol?: string; auth_password?: string;
|
||||
priv_protocol?: string; priv_password?: string;
|
||||
};
|
||||
if (!config.community_name || !config.version) {
|
||||
return res.status(400).json({ error: 'community_name and version are required' });
|
||||
}
|
||||
|
||||
const routers = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'router' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
routers.map(async (r: DeviceRow) => {
|
||||
const collector = new DeviceCollector(r);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setSnmpConfig(config);
|
||||
return { id: r.id, name: r.name, success: true };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return { id: routers[i].id, name: routers[i].name, success: false, error: (r.reason as Error).message };
|
||||
});
|
||||
|
||||
return res.json({ applied: statuses.filter(s => s.success).length, total: routers.length, results: statuses });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/search?q=<term>
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (q.length < 2) {
|
||||
return res.json({ devices: [], clients: [], events: [] });
|
||||
}
|
||||
|
||||
const like = `%${q}%`;
|
||||
|
||||
const [devices, clients, events] = await Promise.all([
|
||||
query(
|
||||
`SELECT id, name, ip_address, model, device_type, status
|
||||
FROM devices
|
||||
WHERE name ILIKE $1 OR ip_address ILIKE $1 OR model ILIKE $1 OR serial_number ILIKE $1
|
||||
ORDER BY (status = 'online') DESC, name ASC
|
||||
LIMIT 6`,
|
||||
[like]
|
||||
),
|
||||
query(
|
||||
`SELECT c.mac_address, c.hostname, c.ip_address, c.device_id, c.active,
|
||||
d.name as device_name
|
||||
FROM clients c
|
||||
LEFT JOIN devices d ON d.id = c.device_id
|
||||
WHERE c.mac_address ILIKE $1 OR c.hostname ILIKE $1 OR c.ip_address ILIKE $1
|
||||
ORDER BY c.active DESC, c.last_seen DESC
|
||||
LIMIT 6`,
|
||||
[like]
|
||||
),
|
||||
query(
|
||||
`SELECT e.id, e.message, e.severity, e.event_time, e.topic, e.device_id,
|
||||
d.name as device_name
|
||||
FROM events e
|
||||
LEFT JOIN devices d ON d.id = e.device_id
|
||||
WHERE e.message ILIKE $1 OR e.topic ILIKE $1 OR d.name ILIKE $1
|
||||
ORDER BY e.event_time DESC
|
||||
LIMIT 6`,
|
||||
[like]
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({ devices, clients, events });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/settings
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const settings = await query(`SELECT key, value FROM app_settings ORDER BY key`);
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const s of settings as { key: string; value: unknown }[]) {
|
||||
map[s.key] = s.value;
|
||||
}
|
||||
res.json(map);
|
||||
});
|
||||
|
||||
// PUT /api/settings
|
||||
router.put('/', requireAdmin, async (req: Request, res: Response) => {
|
||||
const updates = req.body as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
await query(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[key, JSON.stringify(value)]
|
||||
);
|
||||
}
|
||||
res.json({ message: 'Settings updated' });
|
||||
});
|
||||
|
||||
// GET /api/settings/users
|
||||
router.get('/users', requireAdmin, async (_req: Request, res: Response) => {
|
||||
const users = await query(
|
||||
`SELECT id, username, role, created_at FROM users ORDER BY username`
|
||||
);
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// POST /api/settings/users
|
||||
router.post('/users', requireAdmin, async (req: Request, res: Response) => {
|
||||
const { username, password, role = 'viewer' } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'username and password required' });
|
||||
}
|
||||
const validRoles = ['admin', 'operator', 'viewer'];
|
||||
if (!validRoles.includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role. Must be admin, operator, or viewer' });
|
||||
}
|
||||
|
||||
const existing = await queryOne(`SELECT id FROM users WHERE username = $1`, [username]);
|
||||
if (existing) return res.status(409).json({ error: 'Username already exists' });
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const rows = await query<{ id: number }>(
|
||||
`INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id`,
|
||||
[username, hash, role]
|
||||
);
|
||||
const created = await queryOne(
|
||||
`SELECT id, username, role, created_at FROM users WHERE id = $1`,
|
||||
[rows[0].id]
|
||||
);
|
||||
return res.status(201).json(created);
|
||||
});
|
||||
|
||||
// PUT /api/settings/users/:id - update role and/or reset password
|
||||
router.put('/users/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||
const userId = parseInt(req.params.id);
|
||||
const { role, password } = req.body;
|
||||
|
||||
const existing = await queryOne<{ id: number; role: string }>(
|
||||
`SELECT id, role FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
if (!existing) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// Cannot demote yourself
|
||||
if (userId === req.user!.userId && role && role !== 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot change your own role away from admin' });
|
||||
}
|
||||
|
||||
const validRoles = ['admin', 'operator', 'viewer'];
|
||||
if (role && !validRoles.includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
|
||||
if (role) {
|
||||
await query(`UPDATE users SET role = $1 WHERE id = $2`, [role, userId]);
|
||||
}
|
||||
if (password) {
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, userId]);
|
||||
}
|
||||
|
||||
const updated = await queryOne(
|
||||
`SELECT id, username, role, created_at FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
// DELETE /api/settings/users/:id
|
||||
router.delete('/users/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||
if (parseInt(req.params.id) === req.user!.userId) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
const result = await query(`DELETE FROM users WHERE id = $1 RETURNING id`, [req.params.id]);
|
||||
if (!result.length) return res.status(404).json({ error: 'User not found' });
|
||||
return res.json({ message: 'User deleted' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/switches — all switch devices with port statistics
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const switches = await query(`
|
||||
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
|
||||
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
|
||||
COUNT(i.id) FILTER (WHERE i.running = true AND i.disabled = false) AS ports_up,
|
||||
COUNT(i.id) FILTER (WHERE i.running = false AND i.disabled = false) AS ports_down,
|
||||
COUNT(i.id) FILTER (WHERE i.disabled = true) AS ports_disabled,
|
||||
COUNT(i.id) AS ports_total
|
||||
FROM devices d
|
||||
LEFT JOIN interfaces i ON i.device_id = d.id
|
||||
AND (i.type ILIKE 'ether%' OR i.type ILIKE 'sfp%'
|
||||
OR i.name ILIKE 'ether%' OR i.name ILIKE 'sfp%')
|
||||
WHERE d.device_type = 'switch'
|
||||
GROUP BY d.id
|
||||
ORDER BY d.name ASC
|
||||
`);
|
||||
res.json(switches);
|
||||
});
|
||||
|
||||
// GET /api/switches/lldp — LLDP enabled/disabled status per online switch
|
||||
router.get('/lldp', async (_req: Request, res: Response) => {
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
switches.map(async (sw: DeviceRow) => {
|
||||
const collector = new DeviceCollector(sw);
|
||||
try {
|
||||
await collector.connect();
|
||||
const lldp = await collector.getLldpEnabled();
|
||||
return { id: sw.id, name: sw.name, ip_address: sw.ip_address, ...lldp };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: switches[i].id,
|
||||
name: switches[i].name,
|
||||
ip_address: switches[i].ip_address,
|
||||
enabled: null as boolean | null,
|
||||
protocol: null as string | null,
|
||||
error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(statuses);
|
||||
});
|
||||
|
||||
// PUT /api/switches/lldp — enable or disable LLDP on all online switches
|
||||
router.put('/lldp', requireWrite, async (req: Request, res: Response) => {
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: '"enabled" (boolean) is required' });
|
||||
}
|
||||
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
switches.map(async (sw: DeviceRow) => {
|
||||
const collector = new DeviceCollector(sw);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setLldpEnabled(enabled);
|
||||
return { id: sw.id, name: sw.name, success: true };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: switches[i].id,
|
||||
name: switches[i].name,
|
||||
success: false,
|
||||
error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({
|
||||
applied: statuses.filter(s => s.success).length,
|
||||
total: switches.length,
|
||||
results: statuses,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/switches/snmp — SNMP config/status per online switch
|
||||
router.get('/snmp', async (_req: Request, res: Response) => {
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
switches.map(async (sw: DeviceRow) => {
|
||||
const collector = new DeviceCollector(sw);
|
||||
try {
|
||||
await collector.connect();
|
||||
const snmp = await collector.getSnmpConfig();
|
||||
return { id: sw.id, name: sw.name, ip_address: sw.ip_address, ...snmp };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
id: switches[i].id, name: switches[i].name, ip_address: switches[i].ip_address,
|
||||
enabled: null as boolean | null, error: (r.reason as Error).message,
|
||||
};
|
||||
});
|
||||
res.json(statuses);
|
||||
});
|
||||
|
||||
// PUT /api/switches/snmp — apply SNMP config to all online switches
|
||||
router.put('/snmp', requireWrite, async (req: Request, res: Response) => {
|
||||
const config = req.body as {
|
||||
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
auth_protocol?: string; auth_password?: string;
|
||||
priv_protocol?: string; priv_password?: string;
|
||||
};
|
||||
if (!config.community_name || !config.version) {
|
||||
return res.status(400).json({ error: 'community_name and version are required' });
|
||||
}
|
||||
|
||||
const switches = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE device_type = 'switch' AND status = 'online'`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
switches.map(async (sw: DeviceRow) => {
|
||||
const collector = new DeviceCollector(sw);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setSnmpConfig(config);
|
||||
return { id: sw.id, name: sw.name, success: true };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const statuses = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return { id: switches[i].id, name: switches[i].name, success: false, error: (r.reason as Error).message };
|
||||
});
|
||||
|
||||
return res.json({ applied: statuses.filter(s => s.success).length, total: switches.length, results: statuses });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { PollerService } from '../services/PollerService';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
let pollerService: PollerService | null = null;
|
||||
export function setPollerService(p: PollerService): void {
|
||||
pollerService = p;
|
||||
}
|
||||
|
||||
// GET /api/topology
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
// Protocol priority: lldp (point-to-point) > cdp > mndp (L2 broadcast, can be indirect)
|
||||
const PROTO_RANK: Record<string, number> = { lldp: 0, cdp: 1, mndp: 2 };
|
||||
const protoRank = (p: string | null) => PROTO_RANK[p ?? ''] ?? 3;
|
||||
|
||||
interface LinkRow {
|
||||
id: number;
|
||||
from_device_id: number | null;
|
||||
from_interface: string | null;
|
||||
to_interface: string | null;
|
||||
to_device_id: number | null;
|
||||
neighbor_address: string | null;
|
||||
neighbor_identity: string | null;
|
||||
neighbor_platform: string | null;
|
||||
neighbor_mac: string | null;
|
||||
stp_role: string | null;
|
||||
stp_state: string | null;
|
||||
bridge_name: string | null;
|
||||
neighbor_caps: string | null;
|
||||
link_type: string | null;
|
||||
discovered_by: string | null;
|
||||
from_device_name: string | null;
|
||||
to_device_name: string | null;
|
||||
}
|
||||
|
||||
const [devices, allLinks] = await Promise.all([
|
||||
query(
|
||||
`SELECT id, name, ip_address, model, device_type, status, ros_version
|
||||
FROM devices ORDER BY name ASC`
|
||||
),
|
||||
query<LinkRow>(
|
||||
`SELECT tl.*,
|
||||
fd.name as from_device_name, fd.ip_address as from_device_ip,
|
||||
td.name as to_device_name, td.ip_address as to_device_ip
|
||||
FROM topology_links tl
|
||||
LEFT JOIN devices fd ON fd.id = tl.from_device_id
|
||||
LEFT JOIN devices td ON td.id = tl.to_device_id
|
||||
ORDER BY tl.discovered_at DESC`
|
||||
),
|
||||
]);
|
||||
|
||||
// Deduplicate: for each (from_device, neighbor) pair keep only the highest-priority
|
||||
// protocol link. This removes MNDP duplicates when the same neighbor was also found
|
||||
// via LLDP or CDP on the same or a better-quality path.
|
||||
const bestLinks = new Map<string, LinkRow>();
|
||||
for (const link of allLinks as LinkRow[]) {
|
||||
if (!link.from_device_id) continue;
|
||||
const neighborKey = link.to_device_id
|
||||
? String(link.to_device_id)
|
||||
: (link.neighbor_mac || link.neighbor_address || link.neighbor_identity || '');
|
||||
const key = `${link.from_device_id}::${neighborKey}`;
|
||||
const existing = bestLinks.get(key);
|
||||
if (!existing || protoRank(link.link_type) < protoRank(existing.link_type)) {
|
||||
bestLinks.set(key, link);
|
||||
}
|
||||
}
|
||||
const links = Array.from(bestLinks.values());
|
||||
|
||||
// Build synthetic external nodes from unresolved neighbor entries
|
||||
const externalMap = new Map<string, {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
platform: string;
|
||||
mac: string;
|
||||
caps: string;
|
||||
}>();
|
||||
|
||||
for (const link of links) {
|
||||
if (link.to_device_id) continue; // resolved — skip
|
||||
const key = link.neighbor_address || link.neighbor_mac || link.neighbor_identity || '';
|
||||
if (!key) continue;
|
||||
if (!externalMap.has(key)) {
|
||||
const safeId = key.replace(/[.: ]/g, '');
|
||||
externalMap.set(key, {
|
||||
id: `ext-${safeId}`,
|
||||
name: link.neighbor_identity || link.neighbor_address || 'Unknown',
|
||||
address: link.neighbor_address || '',
|
||||
platform: link.neighbor_platform || '',
|
||||
mac: link.neighbor_mac || '',
|
||||
caps: link.neighbor_caps || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ devices, links, externalNodes: Array.from(externalMap.values()) });
|
||||
});
|
||||
|
||||
// POST /api/topology/discover - trigger neighbor discovery on all devices
|
||||
router.post('/discover', requireWrite, async (_req: Request, res: Response) => {
|
||||
const devices = await query<{ id: number }>(`SELECT id FROM devices WHERE status='online'`);
|
||||
|
||||
if (pollerService) {
|
||||
for (const d of devices) {
|
||||
await pollerService.scheduleDeviceSync(d.id, 'slow');
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: `Discovery triggered for ${devices.length} device(s)` });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,704 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
import { lookupVendor } from '../utils/oui';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getAP(id: number): Promise<DeviceRow | null> {
|
||||
const rows = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE id = $1 AND device_type = 'wireless_ap'`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ─── Bulk SSID deployment ─────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/wireless/ssid/bulk — create the same SSID on multiple APs simultaneously
|
||||
router.post('/ssid/bulk', requireWrite, async (req: Request, res: Response) => {
|
||||
const { apIds, ...ssidBody } = req.body as { apIds: number[] } & Record<string, unknown>;
|
||||
|
||||
if (!Array.isArray(apIds) || apIds.length === 0) {
|
||||
return res.status(400).json({ error: '"apIds" must be a non-empty array' });
|
||||
}
|
||||
if (!ssidBody.ssid) {
|
||||
return res.status(400).json({ error: '"ssid" is required' });
|
||||
}
|
||||
|
||||
const placeholders = apIds.map((_, i) => `$${i + 1}`).join(',');
|
||||
const aps = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE id IN (${placeholders}) AND device_type = 'wireless_ap'`,
|
||||
apIds
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
aps.map(async (ap) => {
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const pkg = await collector.detectWifiPackage();
|
||||
const autoName = await collector.getNextInterfaceName();
|
||||
|
||||
// Normalize mode to the correct value for this AP's package type
|
||||
let resolvedMode = String(ssidBody.mode || 'ap-bridge');
|
||||
if (pkg === 'wifi' && (resolvedMode === 'ap-bridge' || resolvedMode === 'bridge')) {
|
||||
resolvedMode = 'ap';
|
||||
} else if (pkg !== 'wifi' && resolvedMode === 'ap') {
|
||||
resolvedMode = 'ap-bridge';
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {
|
||||
name: autoName,
|
||||
ssid: String(ssidBody.ssid),
|
||||
mode: resolvedMode,
|
||||
disabled: ssidBody.disabled ? 'yes' : 'no',
|
||||
};
|
||||
const fieldMap: Record<string, string> = {
|
||||
band: 'band', frequency: 'frequency', channel_width: 'channel-width',
|
||||
tx_power: 'tx-power', tx_power_mode: 'tx-power-mode',
|
||||
antenna_gain: 'antenna-gain', country: 'country', installation: 'installation',
|
||||
security_profile: 'security-profile', passphrase: 'passphrase',
|
||||
};
|
||||
for (const [key, rosKey] of Object.entries(fieldMap)) {
|
||||
if (ssidBody[key]) params[rosKey] = String(ssidBody[key]);
|
||||
}
|
||||
if (ssidBody.authentication_types) {
|
||||
params['authentication-types'] = Array.isArray(ssidBody.authentication_types)
|
||||
? (ssidBody.authentication_types as string[]).join(',')
|
||||
: String(ssidBody.authentication_types);
|
||||
}
|
||||
await collector.addWirelessInterface(params);
|
||||
await collector.collectWirelessInterfaces();
|
||||
return { id: ap.id, name: ap.name as string, ok: true };
|
||||
} catch (err) {
|
||||
return { id: ap.id, name: ap.name as string, ok: false, error: (err as Error).message };
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const results = settled.map(r =>
|
||||
r.status === 'fulfilled' ? r.value : { id: 0, name: 'unknown', ok: false, error: String(r.reason) }
|
||||
);
|
||||
|
||||
return res.json({ results });
|
||||
});
|
||||
|
||||
// ─── Section overview ─────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless — all wireless_ap devices with aggregated stats
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const aps = await query(`
|
||||
SELECT d.id, d.name, d.ip_address, d.model, d.device_type, d.status, d.last_seen,
|
||||
d.ros_version, d.firmware_version, d.serial_number, d.rack_name, d.rack_slot,
|
||||
COUNT(DISTINCT wi.id) AS radio_count,
|
||||
COALESCE(SUM(wi.registered_clients), 0) AS client_count,
|
||||
COUNT(DISTINCT wi.id) FILTER (WHERE wi.ssid IS NOT NULL AND wi.disabled = false) AS ssid_count
|
||||
FROM devices d
|
||||
LEFT JOIN wireless_interfaces wi ON wi.device_id = d.id
|
||||
WHERE d.device_type = 'wireless_ap'
|
||||
GROUP BY d.id
|
||||
ORDER BY d.name ASC
|
||||
`);
|
||||
res.json(aps);
|
||||
});
|
||||
|
||||
// ─── Wireless interfaces (SSIDs) ──────────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless/:id/bridges — bridge list + current port memberships
|
||||
router.get('/:id/bridges', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const [bridges, ports] = await Promise.all([
|
||||
collector.getBridges(),
|
||||
collector.getBridgePorts(),
|
||||
]);
|
||||
return res.json({ bridges, ports });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/interfaces — live from device
|
||||
router.get('/:id/interfaces', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const ifaces = await collector.getWirelessInterfaces();
|
||||
return res.json(ifaces);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/wireless/:id/interfaces — create new SSID/virtual AP
|
||||
router.post('/:id/interfaces', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const {
|
||||
ssid, mode = 'ap-bridge', band, frequency, channel_width,
|
||||
tx_power, tx_power_mode, antenna_gain, country, installation,
|
||||
security_profile, disabled = false,
|
||||
passphrase, authentication_types,
|
||||
bridge, vlan_id,
|
||||
// advanced
|
||||
master_interface,
|
||||
} = req.body as Record<string, string | boolean | number | string[] | undefined>;
|
||||
|
||||
if (!ssid) {
|
||||
return res.status(400).json({ error: '"ssid" is required' });
|
||||
}
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
|
||||
// Auto-generate the next available interface name (e.g. wifi3, wifi4 …)
|
||||
const autoName = await collector.getNextInterfaceName();
|
||||
|
||||
const params: Record<string, string> = {
|
||||
name: autoName,
|
||||
ssid: String(ssid),
|
||||
mode: String(mode),
|
||||
disabled: disabled ? 'yes' : 'no',
|
||||
};
|
||||
if (band) params['band'] = String(band);
|
||||
if (frequency) params['frequency'] = String(frequency);
|
||||
if (channel_width) params['channel-width'] = String(channel_width);
|
||||
if (tx_power) params['tx-power'] = String(tx_power);
|
||||
if (tx_power_mode) params['tx-power-mode'] = String(tx_power_mode);
|
||||
if (antenna_gain !== undefined) params['antenna-gain'] = String(antenna_gain);
|
||||
if (country) params['country'] = String(country);
|
||||
if (installation) params['installation'] = String(installation);
|
||||
if (security_profile) params['security-profile'] = String(security_profile);
|
||||
if (passphrase) params['passphrase'] = String(passphrase);
|
||||
if (authentication_types) {
|
||||
params['authentication-types'] = Array.isArray(authentication_types)
|
||||
? authentication_types.join(',')
|
||||
: String(authentication_types);
|
||||
}
|
||||
if (master_interface) params['master-interface'] = String(master_interface);
|
||||
|
||||
await collector.addWirelessInterface(params);
|
||||
// Bridge port membership — must be done after interface exists
|
||||
if (bridge) {
|
||||
await collector.setInterfaceBridge(
|
||||
autoName,
|
||||
String(bridge),
|
||||
vlan_id ? Number(vlan_id) : undefined,
|
||||
);
|
||||
// Ensure the VLAN ID is also in the bridge VLAN table (untagged)
|
||||
if (vlan_id) {
|
||||
await collector.ensureVlanMembership(autoName, Number(vlan_id));
|
||||
}
|
||||
}
|
||||
// Refresh cache
|
||||
await collector.collectWirelessInterfaces();
|
||||
return res.status(201).json({ ok: true, name: autoName });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/wireless/:id/interfaces/:name — update SSID/radio config
|
||||
router.put('/:id/interfaces/:name', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const ifaceName = req.params.name;
|
||||
const body = req.body as Record<string, string | boolean | number | undefined>;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
const map: Record<string, string> = {
|
||||
ssid: 'ssid', mode: 'mode', band: 'band', frequency: 'frequency',
|
||||
channel_width: 'channel-width', tx_power: 'tx-power',
|
||||
tx_power_mode: 'tx-power-mode', antenna_gain: 'antenna-gain',
|
||||
country: 'country', installation: 'installation',
|
||||
security_profile: 'security-profile', master_interface: 'master-interface',
|
||||
passphrase: 'passphrase',
|
||||
};
|
||||
|
||||
for (const [key, rosKey] of Object.entries(map)) {
|
||||
if (body[key] !== undefined) params[rosKey] = String(body[key]);
|
||||
}
|
||||
if (body.disabled !== undefined) params['disabled'] = body.disabled ? 'yes' : 'no';
|
||||
if (body.authentication_types !== undefined) {
|
||||
params['authentication-types'] = Array.isArray(body.authentication_types)
|
||||
? (body.authentication_types as string[]).join(',')
|
||||
: String(body.authentication_types);
|
||||
}
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setWirelessInterface(ifaceName, params);
|
||||
// Bridge port membership — only touched when 'bridge' key is present in the request
|
||||
if (body.bridge !== undefined) {
|
||||
const bridgeName = body.bridge ? String(body.bridge) : null;
|
||||
const pvid = body.vlan_id ? Number(body.vlan_id) : undefined;
|
||||
await collector.setInterfaceBridge(ifaceName, bridgeName, pvid);
|
||||
// Ensure the VLAN ID is in the bridge VLAN table (untagged)
|
||||
if (bridgeName && body.vlan_id) {
|
||||
await collector.ensureVlanMembership(ifaceName, Number(body.vlan_id));
|
||||
}
|
||||
}
|
||||
await collector.collectWirelessInterfaces();
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/wireless/:id/interfaces/:name
|
||||
router.delete('/:id/interfaces/:name', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.removeWirelessInterface(req.params.name);
|
||||
await query(
|
||||
`DELETE FROM wireless_interfaces WHERE device_id=$1 AND name=$2`,
|
||||
[ap.id, req.params.name]
|
||||
);
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Security Profiles ────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless/:id/security-profiles — live from device
|
||||
router.get('/:id/security-profiles', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const profiles = await collector.getSecurityProfilesLive();
|
||||
return res.json(profiles);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/wireless/:id/security-profiles
|
||||
router.post('/:id/security-profiles', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const {
|
||||
name, mode = 'dynamic-keys',
|
||||
authentication_types, passphrase,
|
||||
unicast_ciphers, group_ciphers, management_protection = 'disabled',
|
||||
management_protection_key,
|
||||
} = req.body as Record<string, string | string[] | undefined>;
|
||||
|
||||
if (!name) return res.status(400).json({ error: '"name" is required' });
|
||||
|
||||
const params: Record<string, string> = {
|
||||
name: String(name),
|
||||
mode: String(mode),
|
||||
'management-protection': String(management_protection),
|
||||
};
|
||||
|
||||
if (authentication_types) {
|
||||
params['authentication-types'] = Array.isArray(authentication_types)
|
||||
? authentication_types.join(',')
|
||||
: String(authentication_types);
|
||||
}
|
||||
if (unicast_ciphers) {
|
||||
params['unicast-ciphers'] = Array.isArray(unicast_ciphers)
|
||||
? unicast_ciphers.join(',')
|
||||
: String(unicast_ciphers);
|
||||
}
|
||||
if (group_ciphers) {
|
||||
params['group-ciphers'] = Array.isArray(group_ciphers)
|
||||
? group_ciphers.join(',')
|
||||
: String(group_ciphers);
|
||||
}
|
||||
if (passphrase) params['passphrase'] = String(passphrase);
|
||||
if (management_protection_key) params['management-protection-key'] = String(management_protection_key);
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.addSecurityProfile(params);
|
||||
await collector.collectSecurityProfiles();
|
||||
return res.status(201).json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/wireless/:id/security-profiles/:name
|
||||
router.put('/:id/security-profiles/:name', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const profileName = req.params.name;
|
||||
const body = req.body as Record<string, string | string[] | undefined>;
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (body.mode) params['mode'] = String(body.mode);
|
||||
if (body.management_protection) params['management-protection'] = String(body.management_protection);
|
||||
if (body.management_protection_key) params['management-protection-key'] = String(body.management_protection_key);
|
||||
if (body.passphrase) params['passphrase'] = String(body.passphrase);
|
||||
|
||||
if (body.authentication_types !== undefined) {
|
||||
params['authentication-types'] = Array.isArray(body.authentication_types)
|
||||
? body.authentication_types.join(',')
|
||||
: String(body.authentication_types);
|
||||
}
|
||||
if (body.unicast_ciphers !== undefined) {
|
||||
params['unicast-ciphers'] = Array.isArray(body.unicast_ciphers)
|
||||
? body.unicast_ciphers.join(',')
|
||||
: String(body.unicast_ciphers);
|
||||
}
|
||||
if (body.group_ciphers !== undefined) {
|
||||
params['group-ciphers'] = Array.isArray(body.group_ciphers)
|
||||
? body.group_ciphers.join(',')
|
||||
: String(body.group_ciphers);
|
||||
}
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.setSecurityProfile(profileName, params);
|
||||
await collector.collectSecurityProfiles();
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/wireless/:id/security-profiles/:name
|
||||
router.delete('/:id/security-profiles/:name', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.removeSecurityProfile(req.params.name);
|
||||
await query(
|
||||
`DELETE FROM wireless_security_profiles WHERE device_id=$1 AND name=$2`,
|
||||
[ap.id, req.params.name]
|
||||
);
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Radio monitoring / diagnostics ──────────────────────────────────────────
|
||||
|
||||
// GET /api/wireless/:id/radios — hardware radio capabilities (wifi package only)
|
||||
router.get('/:id/radios', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const radios = await collector.getWifiRadioInfo();
|
||||
return res.json(radios);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/registration-table — connected wireless clients
|
||||
router.get('/:id/registration-table', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const table = await collector.getWirelessRegistrationTable();
|
||||
return res.json(table);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/monitor/:iface — real-time radio monitor snapshot
|
||||
router.get('/:id/monitor/:iface', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const data = await collector.getWirelessMonitor(req.params.iface);
|
||||
return res.json(data[0] ?? {});
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/scan/:iface — scan for nearby APs (5 s)
|
||||
router.get('/:id/scan/:iface', async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const results = await collector.scanWireless(req.params.iface);
|
||||
return res.json(results);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate raw RouterOS spectral-scan rows (35 k+ per 10 s scan) into one point
|
||||
// per unique frequency. Each row has: freq (MHz, string), magn (dBm, string),
|
||||
// peak (dBm, string). We average magn and keep the highest peak per frequency.
|
||||
function aggregateSpectralRows(
|
||||
rows: Record<string, string>[]
|
||||
): { freq: number; magn: number; peak: number }[] {
|
||||
const map = new Map<number, { sum: number; count: number; peak: number }>();
|
||||
for (const row of rows) {
|
||||
const freq = parseFloat(row['freq'] || '0');
|
||||
const magn = parseInt(row['magn'] || '-120', 10);
|
||||
const peak = parseInt(row['peak'] || magn.toString(), 10);
|
||||
if (freq <= 0) continue;
|
||||
const existing = map.get(freq);
|
||||
if (existing) {
|
||||
existing.sum += magn;
|
||||
existing.count += 1;
|
||||
existing.peak = Math.max(existing.peak, peak);
|
||||
} else {
|
||||
map.set(freq, { sum: magn, count: 1, peak });
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([freq, { sum, count, peak }]) => ({
|
||||
freq,
|
||||
magn: Math.round(sum / count),
|
||||
peak,
|
||||
}));
|
||||
}
|
||||
|
||||
// POST /api/wireless/:id/spectral-scan/:iface — run an on-demand spectral scan,
|
||||
// save the aggregated result and return it immediately.
|
||||
router.post('/:id/spectral-scan/:iface', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const rows = await collector.collectSpectralScan(req.params.iface);
|
||||
if (rows.length === 0) {
|
||||
return res.status(422).json({ error: 'No spectral data returned — interface may not support spectral scan' });
|
||||
}
|
||||
const aggregated = aggregateSpectralRows(rows);
|
||||
const saved = await query<{ id: number; scanned_at: string }>(
|
||||
`INSERT INTO spectral_scan_data (device_id, interface_name, data, scan_type)
|
||||
VALUES ($1, $2, $3, 'manual')
|
||||
RETURNING id, scanned_at`,
|
||||
[ap.id, req.params.iface, JSON.stringify(aggregated)]
|
||||
);
|
||||
return res.json({ id: saved[0].id, scanned_at: saved[0].scanned_at, data: aggregated });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/spectral-history/:iface?limit=5 — return last N scans from DB
|
||||
router.get('/:id/spectral-history/:iface', async (req: Request, res: Response) => {
|
||||
const deviceId = parseInt(req.params.id);
|
||||
const iface = req.params.iface;
|
||||
const limit = Math.min(parseInt(String(req.query.limit || '5'), 10), 20);
|
||||
|
||||
const scans = await query<{ id: number; scanned_at: string; scan_type: string; data: unknown }>(
|
||||
`SELECT id, scanned_at, scan_type, data
|
||||
FROM spectral_scan_data
|
||||
WHERE device_id = $1 AND interface_name = $2
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT $3`,
|
||||
[deviceId, iface, limit]
|
||||
);
|
||||
return res.json(scans);
|
||||
});
|
||||
|
||||
// ─── AP scan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface APBandEntry {
|
||||
bssid: string; vendor: string; signal: number;
|
||||
freq: number; band: string; channel_width: string;
|
||||
}
|
||||
interface APNetworkEntry {
|
||||
ssid: string; security: string; hidden: boolean; entries: APBandEntry[];
|
||||
}
|
||||
|
||||
function normBand(band: string, freq: number): string {
|
||||
if (band.includes('6ghz') || freq >= 5925) return '6 GHz';
|
||||
if (band.includes('5ghz') || (freq >= 4900 && freq < 5925)) return '5 GHz';
|
||||
return '2.4 GHz';
|
||||
}
|
||||
|
||||
function aggregateAPScanRows(
|
||||
allRows: { iface: string; rows: Record<string, string>[] }[]
|
||||
): APNetworkEntry[] {
|
||||
const byKey = new Map<string, APNetworkEntry>();
|
||||
|
||||
for (const { rows } of allRows) {
|
||||
for (const row of rows) {
|
||||
const ssid = row['network-name'] || row['ssid'] || '';
|
||||
const bssid = (row['address'] || row['bssid'] || '').toLowerCase();
|
||||
if (!bssid) continue;
|
||||
const rawSig = row['signal-strength'] || row['signal'] || '-100';
|
||||
const signal = parseInt(rawSig, 10) || -100;
|
||||
const freq = parseFloat(row['frequency'] || row['channel'] || '0');
|
||||
const band = normBand(row['band'] || row['radio-band'] || '', freq);
|
||||
// security: prefer explicit field; if auth-types present but no explicit security, call it WPA
|
||||
const security = row['security']
|
||||
|| (row['authentication-types'] ? 'WPA' : 'open');
|
||||
const channelWidth = row['channel-width'] || '';
|
||||
const key = ssid || `hidden:${bssid}`;
|
||||
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, { ssid, security, hidden: !ssid, entries: [] });
|
||||
}
|
||||
const net = byKey.get(key)!;
|
||||
const existing = net.entries.find(e => e.bssid === bssid && e.freq === freq);
|
||||
if (existing) {
|
||||
if (signal > existing.signal) existing.signal = signal;
|
||||
} else {
|
||||
net.entries.push({
|
||||
bssid,
|
||||
vendor: lookupVendor(bssid),
|
||||
signal,
|
||||
freq,
|
||||
band,
|
||||
channel_width: channelWidth,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values()).sort((a, b) => {
|
||||
const aBest = Math.max(...a.entries.map(e => e.signal));
|
||||
const bBest = Math.max(...b.entries.map(e => e.signal));
|
||||
return bBest - aBest;
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/wireless/:id/ap-scan — scan all interfaces, combine results, save + return
|
||||
router.post('/:id/ap-scan', requireWrite, async (req: Request, res: Response) => {
|
||||
const ap = await getAP(parseInt(req.params.id));
|
||||
if (!ap) return res.status(404).json({ error: 'Wireless AP not found' });
|
||||
|
||||
// Only scan physical radios (master=true). Virtual APs (master=false, e.g. extra SSIDs)
|
||||
// share a radio with their master interface and cannot initiate scans independently.
|
||||
const ifaces = await query<{ name: string }>(
|
||||
`SELECT name FROM wireless_interfaces
|
||||
WHERE device_id = $1
|
||||
AND disabled = FALSE
|
||||
AND (config_json->>'master' IS NULL OR config_json->>'master' != 'false')`,
|
||||
[ap.id]
|
||||
);
|
||||
if (ifaces.length === 0) {
|
||||
return res.status(422).json({ error: 'No active wireless interfaces found' });
|
||||
}
|
||||
|
||||
// Use a separate TCP connection per radio — a 10 s streaming scan can leave
|
||||
// residual bytes in the socket that corrupt a subsequent scan on the same connection.
|
||||
const allRows: { iface: string; rows: Record<string, string>[] }[] = [];
|
||||
for (const iface of ifaces) {
|
||||
const collector = new DeviceCollector(ap);
|
||||
try {
|
||||
await collector.connect();
|
||||
const rows = await collector.scanWireless(iface.name, 10_000);
|
||||
if (rows.length > 0) allRows.push({ iface: iface.name, rows });
|
||||
} catch {
|
||||
// ignore per-interface errors; continue to next radio
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
if (allRows.length === 0) {
|
||||
return res.status(422).json({ error: 'No AP scan data returned' });
|
||||
}
|
||||
|
||||
try {
|
||||
const aggregated = aggregateAPScanRows(allRows);
|
||||
const saved = await query<{ id: number; scanned_at: string }>(
|
||||
`INSERT INTO ap_scan_data (device_id, data, scan_type)
|
||||
VALUES ($1, $2, 'manual') RETURNING id, scanned_at`,
|
||||
[ap.id, JSON.stringify(aggregated)]
|
||||
);
|
||||
return res.json({ id: saved[0].id, scanned_at: saved[0].scanned_at, data: aggregated });
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/wireless/:id/ap-scan-history?limit=5 — last N saved scans
|
||||
router.get('/:id/ap-scan-history', async (req: Request, res: Response) => {
|
||||
const deviceId = parseInt(req.params.id);
|
||||
const limit = Math.min(parseInt(String(req.query.limit || '5'), 10), 20);
|
||||
|
||||
const scans = await query<{ id: number; scanned_at: string; scan_type: string; data: unknown }>(
|
||||
`SELECT id, scanned_at, scan_type, data
|
||||
FROM ap_scan_data
|
||||
WHERE device_id = $1
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT $2`,
|
||||
[deviceId, limit]
|
||||
);
|
||||
return res.json(scans);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,355 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import { query } from '../config/database';
|
||||
|
||||
export type AlertEventType =
|
||||
| 'device_offline'
|
||||
| 'device_online'
|
||||
| 'log_error'
|
||||
| 'log_warning'
|
||||
| 'high_cpu'
|
||||
| 'high_memory'
|
||||
| 'cert_expiry'
|
||||
| 'device_discovered'
|
||||
| 'firmware_update_available';
|
||||
|
||||
export interface AlertContext {
|
||||
deviceId?: number;
|
||||
deviceName?: string;
|
||||
details?: string;
|
||||
/** Override the per-alert cooldown key (default: `${eventType}:${deviceId|'global'}`). */
|
||||
cooldownKey?: string;
|
||||
}
|
||||
|
||||
interface AlertRule {
|
||||
event_type: string;
|
||||
enabled: boolean;
|
||||
threshold: number | null;
|
||||
cooldown_min: number;
|
||||
}
|
||||
|
||||
interface AlertChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'email' | 'slack' | 'discord' | 'telegram';
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// In-memory cooldown map (keyed by "eventType:deviceId")
|
||||
// Persists across calls within the same process lifetime
|
||||
const cooldownUntil = new Map<string, number>();
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
device_offline: 'Device Offline',
|
||||
device_online: 'Device Online (Recovery)',
|
||||
log_error: 'Log Error Detected',
|
||||
log_warning: 'Log Warning Detected',
|
||||
high_cpu: 'High CPU Usage',
|
||||
high_memory: 'High Memory Usage',
|
||||
cert_expiry: 'Certificate Expiring Soon',
|
||||
device_discovered: 'New Device Discovered',
|
||||
firmware_update_available: 'Firmware Update Available',
|
||||
};
|
||||
|
||||
const EVENT_EMOJI: Record<string, string> = {
|
||||
device_offline: '🔴',
|
||||
device_online: '🟢',
|
||||
log_error: '❌',
|
||||
log_warning: '⚠️',
|
||||
high_cpu: '🔥',
|
||||
high_memory: '🔥',
|
||||
cert_expiry: '🔐',
|
||||
device_discovered: '🔍',
|
||||
firmware_update_available: '🔄',
|
||||
};
|
||||
|
||||
export class AlertService {
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
async dispatch(eventType: AlertEventType, message: string, ctx: AlertContext = {}): Promise<void> {
|
||||
try {
|
||||
const rule = await this.getRule(eventType);
|
||||
if (!rule?.enabled) return;
|
||||
|
||||
// Cooldown check
|
||||
const cooldownKey = ctx.cooldownKey ?? `${eventType}:${ctx.deviceId ?? 'global'}`;
|
||||
const cooldownMs = (rule.cooldown_min ?? 15) * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const until = cooldownUntil.get(cooldownKey) ?? 0;
|
||||
if (now < until) return;
|
||||
cooldownUntil.set(cooldownKey, now + cooldownMs);
|
||||
|
||||
// Get enabled channels
|
||||
const channels = await query<AlertChannel>(
|
||||
`SELECT * FROM alert_channels WHERE enabled = true ORDER BY id`
|
||||
);
|
||||
if (channels.length === 0) return;
|
||||
|
||||
// Dispatch concurrently, don't let one failure block others
|
||||
const notified: string[] = [];
|
||||
await Promise.allSettled(
|
||||
channels.map(async (ch) => {
|
||||
try {
|
||||
await this.sendToChannel(ch, eventType, message, ctx);
|
||||
notified.push(ch.name);
|
||||
} catch (err) {
|
||||
console.error(`[AlertService] Channel "${ch.name}" failed:`, (err as Error).message);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Log to history — use subquery for device_id so a deleted device doesn't cause a FK violation
|
||||
await query(
|
||||
`INSERT INTO alert_history (event_type, device_id, device_name, message, channels_notified)
|
||||
VALUES ($1, (SELECT id FROM devices WHERE id = $2), $3, $4, $5)`,
|
||||
[eventType, ctx.deviceId ?? null, ctx.deviceName ?? null, message, JSON.stringify(notified)]
|
||||
);
|
||||
|
||||
if (notified.length > 0) {
|
||||
console.log(`[AlertService] ${eventType} → sent to: ${notified.join(', ')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AlertService] Dispatch error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async getRule(eventType: string): Promise<AlertRule | null> {
|
||||
const rows = await query<AlertRule>(
|
||||
`SELECT * FROM alert_rules WHERE event_type = $1`,
|
||||
[eventType]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async getRules(): Promise<AlertRule[]> {
|
||||
return query<AlertRule>(`SELECT * FROM alert_rules ORDER BY event_type`);
|
||||
}
|
||||
|
||||
async upsertRule(eventType: string, data: Partial<AlertRule>): Promise<AlertRule> {
|
||||
const rows = await query<AlertRule>(
|
||||
`INSERT INTO alert_rules (event_type, enabled, threshold, cooldown_min)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (event_type) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
threshold = EXCLUDED.threshold,
|
||||
cooldown_min = EXCLUDED.cooldown_min,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[
|
||||
eventType,
|
||||
data.enabled ?? false,
|
||||
data.threshold ?? null,
|
||||
data.cooldown_min ?? 15,
|
||||
]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/** Send a test message to a single channel (ignores cooldown/rules). */
|
||||
async testChannel(channelId: number): Promise<void> {
|
||||
const rows = await query<AlertChannel>(
|
||||
`SELECT * FROM alert_channels WHERE id = $1`,
|
||||
[channelId]
|
||||
);
|
||||
const ch = rows[0];
|
||||
if (!ch) throw new Error('Channel not found');
|
||||
await this.sendToChannel(
|
||||
ch,
|
||||
'device_online',
|
||||
'This is a test alert from Mikrotik Manager.',
|
||||
{ deviceName: 'Test Device' }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sending ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private async sendToChannel(
|
||||
ch: AlertChannel,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
switch (ch.type) {
|
||||
case 'email': await this.sendEmail(ch.config, eventType, message, ctx); break;
|
||||
case 'slack': await this.sendSlack(ch.config, eventType, message, ctx); break;
|
||||
case 'discord': await this.sendDiscord(ch.config, eventType, message, ctx); break;
|
||||
case 'telegram': await this.sendTelegram(ch.config, eventType, message, ctx); break;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendEmail(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
const host = cfg.smtp_host as string;
|
||||
const port = (cfg.smtp_port as number) || 587;
|
||||
const secure = !!(cfg.smtp_secure);
|
||||
const user = cfg.smtp_user as string | undefined;
|
||||
const pass = cfg.smtp_pass as string | undefined;
|
||||
const from = (cfg.from_address as string) || user || 'alerts@mikrotik-manager';
|
||||
const recipients = (cfg.recipients as string[]) || [];
|
||||
|
||||
if (!host || recipients.length === 0) {
|
||||
throw new Error('Email channel missing smtp_host or recipients');
|
||||
}
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
auth: user && pass ? { user, pass } : undefined,
|
||||
tls: { rejectUnauthorized: false },
|
||||
} as nodemailer.TransportOptions);
|
||||
|
||||
const label = EVENT_LABELS[eventType] ?? eventType;
|
||||
const subject = `[Mikrotik Manager] ${label}`;
|
||||
const device = ctx.deviceName ? `<b>Device:</b> ${ctx.deviceName}<br>` : '';
|
||||
const details = ctx.details ? `<br><pre>${ctx.details}</pre>` : '';
|
||||
|
||||
const html = `
|
||||
<div style="font-family:sans-serif;max-width:600px">
|
||||
<div style="background:#1e40af;color:#fff;padding:12px 20px;border-radius:8px 8px 0 0">
|
||||
<strong>Mikrotik Manager Alert</strong>
|
||||
</div>
|
||||
<div style="border:1px solid #e5e7eb;border-top:none;padding:20px;border-radius:0 0 8px 8px">
|
||||
<h2 style="margin:0 0 12px;color:#1f2937">${EVENT_EMOJI[eventType] ?? '🔔'} ${label}</h2>
|
||||
<p style="color:#374151">${message}</p>
|
||||
<div style="color:#6b7280;font-size:13px">${device}${details}</div>
|
||||
<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb">
|
||||
<p style="font-size:12px;color:#9ca3af">Sent by Mikrotik Manager · ${new Date().toUTCString()}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
await transport.sendMail({ from, to: recipients.join(', '), subject, html });
|
||||
}
|
||||
|
||||
private async sendSlack(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
const webhookUrl = cfg.webhook_url as string;
|
||||
if (!webhookUrl) throw new Error('Slack channel missing webhook_url');
|
||||
|
||||
const label = EVENT_LABELS[eventType] ?? eventType;
|
||||
const emoji = EVENT_EMOJI[eventType] ?? ':bell:';
|
||||
const device = ctx.deviceName ? `\n*Device:* ${ctx.deviceName}` : '';
|
||||
const detail = ctx.details ? `\n\`\`\`${ctx.details}\`\`\`` : '';
|
||||
|
||||
const body = JSON.stringify({
|
||||
text: `${emoji} *${label}*`,
|
||||
attachments: [{
|
||||
color: this.slackColor(eventType),
|
||||
text: `${message}${device}${detail}`,
|
||||
footer: 'Mikrotik Manager',
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
}],
|
||||
});
|
||||
|
||||
await this.postJson(webhookUrl, body);
|
||||
}
|
||||
|
||||
private async sendDiscord(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
const webhookUrl = cfg.webhook_url as string;
|
||||
if (!webhookUrl) throw new Error('Discord channel missing webhook_url');
|
||||
|
||||
const label = EVENT_LABELS[eventType] ?? eventType;
|
||||
const device = ctx.deviceName ? `\n**Device:** ${ctx.deviceName}` : '';
|
||||
const detail = ctx.details ? `\n\`\`\`${ctx.details}\`\`\`` : '';
|
||||
|
||||
const body = JSON.stringify({
|
||||
embeds: [{
|
||||
title: `${EVENT_EMOJI[eventType] ?? '🔔'} ${label}`,
|
||||
description: `${message}${device}${detail}`,
|
||||
color: this.discordColor(eventType),
|
||||
footer: { text: 'Mikrotik Manager' },
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
});
|
||||
|
||||
await this.postJson(webhookUrl, body);
|
||||
}
|
||||
|
||||
private async sendTelegram(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
const botToken = cfg.bot_token as string;
|
||||
const chatId = cfg.chat_id as string;
|
||||
if (!botToken || !chatId) throw new Error('Telegram channel missing bot_token or chat_id');
|
||||
|
||||
const label = EVENT_LABELS[eventType] ?? eventType;
|
||||
const device = ctx.deviceName ? `\n📡 <b>Device:</b> ${ctx.deviceName}` : '';
|
||||
const detail = ctx.details ? `\n<pre>${ctx.details}</pre>` : '';
|
||||
|
||||
const text = `${EVENT_EMOJI[eventType] ?? '🔔'} <b>${label}</b>\n${message}${device}${detail}`;
|
||||
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
const body = JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' });
|
||||
|
||||
await this.postJson(url, body);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private postJson(url: string, body: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (isHttps ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
private slackColor(eventType: string): string {
|
||||
if (eventType === 'device_online') return 'good';
|
||||
if (['device_offline', 'log_error', 'high_cpu', 'high_memory'].includes(eventType)) return 'danger';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
private discordColor(eventType: string): number {
|
||||
if (eventType === 'device_online') return 0x22c55e; // green
|
||||
if (eventType === 'log_warning' || eventType === 'cert_expiry') return 0xf59e0b; // amber
|
||||
return 0xef4444; // red
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const alertService = new AlertService();
|
||||
@@ -0,0 +1,217 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Client as SSHClient } from 'ssh2';
|
||||
import { query } from '../config/database';
|
||||
import { decrypt } from '../utils/crypto';
|
||||
|
||||
const BACKUPS_DIR = process.env.BACKUPS_DIR || '/app/backups';
|
||||
|
||||
export interface BackupDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
ssh_port: number;
|
||||
ssh_username?: string;
|
||||
ssh_password_encrypted?: string;
|
||||
api_username: string;
|
||||
api_password_encrypted: string;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
constructor() {
|
||||
// Ensure backups directory exists
|
||||
if (!fs.existsSync(BACKUPS_DIR)) {
|
||||
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async createBackup(device: BackupDevice, notes?: string): Promise<number> {
|
||||
const sshUser = device.ssh_username || device.api_username;
|
||||
const sshPass = device.ssh_password_encrypted
|
||||
? decrypt(device.ssh_password_encrypted)
|
||||
: decrypt(device.api_password_encrypted);
|
||||
|
||||
const exportContent = await this.sshExport(
|
||||
device.ip_address,
|
||||
device.ssh_port || 22,
|
||||
sshUser,
|
||||
sshPass
|
||||
);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `${device.name.replace(/[^a-z0-9]/gi, '_')}_${timestamp}.rsc`;
|
||||
const filePath = path.join(BACKUPS_DIR, String(device.id), filename);
|
||||
|
||||
// Ensure device backup directory exists
|
||||
const deviceDir = path.join(BACKUPS_DIR, String(device.id));
|
||||
if (!fs.existsSync(deviceDir)) {
|
||||
fs.mkdirSync(deviceDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, exportContent, 'utf8');
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
const rows = await query<{ id: number }>(
|
||||
`INSERT INTO backups (device_id, filename, file_path, size_bytes, backup_type, notes)
|
||||
VALUES ($1,$2,$3,$4,'manual',$5) RETURNING id`,
|
||||
[device.id, filename, filePath, stats.size, notes || null]
|
||||
);
|
||||
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
async restoreBackup(backupId: number): Promise<void> {
|
||||
const backup = await query<{
|
||||
file_path: string;
|
||||
device_id: number;
|
||||
filename: string;
|
||||
}>(`SELECT b.*, d.ip_address, d.ssh_port, d.ssh_username, d.ssh_password_encrypted, d.api_username, d.api_password_encrypted
|
||||
FROM backups b JOIN devices d ON d.id = b.device_id
|
||||
WHERE b.id = $1`, [backupId]);
|
||||
|
||||
if (!backup[0]) throw new Error('Backup not found');
|
||||
|
||||
const b = backup[0] as unknown as BackupDevice & { file_path: string };
|
||||
const content = fs.readFileSync(b.file_path, 'utf8');
|
||||
|
||||
const sshUser = b.ssh_username || b.api_username;
|
||||
const sshPass = b.ssh_password_encrypted
|
||||
? decrypt(b.ssh_password_encrypted)
|
||||
: decrypt(b.api_password_encrypted);
|
||||
|
||||
await this.sshImport(b.ip_address, b.ssh_port || 22, sshUser, sshPass, content);
|
||||
}
|
||||
|
||||
async deleteBackup(backupId: number): Promise<void> {
|
||||
const rows = await query<{ file_path: string }>(
|
||||
`DELETE FROM backups WHERE id = $1 RETURNING file_path`,
|
||||
[backupId]
|
||||
);
|
||||
if (rows[0]?.file_path && fs.existsSync(rows[0].file_path)) {
|
||||
fs.unlinkSync(rows[0].file_path);
|
||||
}
|
||||
}
|
||||
|
||||
getBackupFilePath(backupId: number, deviceId: number): string | null {
|
||||
const rows = fs.readdirSync(path.join(BACKUPS_DIR, String(deviceId))).filter(
|
||||
(f) => f.includes(String(backupId))
|
||||
);
|
||||
if (!rows.length) return null;
|
||||
return path.join(BACKUPS_DIR, String(deviceId), rows[0]);
|
||||
}
|
||||
|
||||
private sshExport(
|
||||
host: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
let output = '';
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error('SSH timeout during backup'));
|
||||
}, 30_000);
|
||||
|
||||
conn.on('ready', () => {
|
||||
conn.exec('/export compact', (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
console.warn('SSH stderr:', data.toString());
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
resolve(output);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
conn.connect({ host, port, username, password, readyTimeout: 10_000 });
|
||||
});
|
||||
}
|
||||
|
||||
private sshImport(
|
||||
host: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error('SSH timeout during restore'));
|
||||
}, 60_000);
|
||||
|
||||
conn.on('ready', () => {
|
||||
// Upload via SFTP then execute
|
||||
conn.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const remoteFile = '/restore_config.rsc';
|
||||
const writeStream = sftp.createWriteStream(remoteFile);
|
||||
|
||||
writeStream.on('close', () => {
|
||||
// Execute the import
|
||||
conn.exec(`/import file-name=${remoteFile}`, (err2, stream) => {
|
||||
if (err2) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
return reject(err2);
|
||||
}
|
||||
|
||||
stream.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
stream.on('error', (e: Error) => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
writeStream.on('error', (e: Error) => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
reject(e);
|
||||
});
|
||||
|
||||
writeStream.end(Buffer.from(content, 'utf8'));
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
conn.connect({ host, port, username, password, readyTimeout: 10_000 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import { createRedisConnection } from '../config/redis';
|
||||
import { query } from '../config/database';
|
||||
import { DeviceCollector, DeviceRow } from './mikrotik/DeviceCollector';
|
||||
import { Server as SocketServer } from 'socket.io';
|
||||
import { getWriteApi } from '../config/influxdb';
|
||||
import { Point } from '@influxdata/influxdb-client';
|
||||
import { alertService } from './AlertService';
|
||||
|
||||
interface PollJob {
|
||||
deviceId: number;
|
||||
type: 'fast' | 'slow' | 'logs' | 'full' | 'macscan' | 'spectral' | 'apscan';
|
||||
}
|
||||
|
||||
export class PollerService {
|
||||
private fastQueue: Queue;
|
||||
private slowQueue: Queue;
|
||||
private logsQueue: Queue;
|
||||
private fastWorker: Worker | null = null;
|
||||
private slowWorker: Worker | null = null;
|
||||
private logsWorker: Worker | null = null;
|
||||
private schedulerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private io: SocketServer | null = null;
|
||||
|
||||
constructor() {
|
||||
const conn1 = createRedisConnection();
|
||||
const conn2 = createRedisConnection();
|
||||
const conn3 = createRedisConnection();
|
||||
|
||||
this.fastQueue = new Queue('poll-fast', { connection: conn1 });
|
||||
this.slowQueue = new Queue('poll-slow', { connection: conn2 });
|
||||
this.logsQueue = new Queue('poll-logs', { connection: conn3 });
|
||||
}
|
||||
|
||||
setSocketServer(io: SocketServer): void {
|
||||
this.io = io;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.startWorkers();
|
||||
this.startScheduler();
|
||||
console.log('PollerService started');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.schedulerInterval) {
|
||||
clearInterval(this.schedulerInterval);
|
||||
}
|
||||
await this.fastWorker?.close();
|
||||
await this.slowWorker?.close();
|
||||
await this.logsWorker?.close();
|
||||
await this.fastQueue.close();
|
||||
await this.slowQueue.close();
|
||||
await this.logsQueue.close();
|
||||
}
|
||||
|
||||
async scheduleDeviceSync(deviceId: number, type: PollJob['type'] = 'full'): Promise<void> {
|
||||
const jobData: PollJob = { deviceId, type };
|
||||
if (type === 'full') {
|
||||
await this.fastQueue.add('device-full-sync', jobData, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
});
|
||||
} else if (type === 'fast') {
|
||||
await this.fastQueue.add('device-fast-poll', jobData, { attempts: 2 });
|
||||
} else if (type === 'slow') {
|
||||
await this.slowQueue.add('device-slow-poll', jobData, { attempts: 2 });
|
||||
} else if (type === 'logs') {
|
||||
await this.logsQueue.add('device-logs-poll', jobData, { attempts: 2 });
|
||||
} else if (type === 'macscan') {
|
||||
await this.fastQueue.add('device-macscan', jobData, { attempts: 1 });
|
||||
} else if (type === 'spectral') {
|
||||
await this.slowQueue.add('device-spectral', jobData, { attempts: 1 });
|
||||
} else if (type === 'apscan') {
|
||||
await this.slowQueue.add('device-apscan', jobData, { attempts: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
private startScheduler(): void {
|
||||
// Schedule polls every 30 seconds
|
||||
this.schedulerInterval = setInterval(async () => {
|
||||
await this.schedulePollCycle();
|
||||
}, 30_000);
|
||||
|
||||
// Also run immediately
|
||||
setTimeout(() => this.schedulePollCycle(), 5000);
|
||||
}
|
||||
|
||||
private async schedulePollCycle(): Promise<void> {
|
||||
try {
|
||||
const appSettings = await this.getAppSettings();
|
||||
const macScanEnabled = appSettings['mac_scan_enabled'] !== false;
|
||||
const macScanInterval = (appSettings['mac_scan_interval'] as number) || 300;
|
||||
const reverseDnsEnabled = appSettings['reverse_dns_enabled'] === true;
|
||||
const spectralEnabled = appSettings['spectral_scan_enabled'] === true;
|
||||
const spectralIntervalHours = (appSettings['spectral_scan_interval_hours'] as number) || 24;
|
||||
const apScanEnabled = appSettings['ap_scan_enabled'] === true;
|
||||
const apScanIntervalHours = (appSettings['ap_scan_interval_hours'] as number) || 24;
|
||||
|
||||
const devices = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE status != 'disabled'`
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
for (const device of devices) {
|
||||
// Fast poll every 30s
|
||||
await this.scheduleDeviceSync(device.id, 'fast');
|
||||
|
||||
// Slow poll every 5min (300s)
|
||||
const slowKey = `poll:slow:${device.id}`;
|
||||
const lastSlow = await this.getTimestamp(slowKey);
|
||||
if (now - lastSlow > 300_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'slow');
|
||||
await this.setTimestamp(slowKey, now);
|
||||
}
|
||||
|
||||
// Logs poll every 60s
|
||||
const logsKey = `poll:logs:${device.id}`;
|
||||
const lastLogs = await this.getTimestamp(logsKey);
|
||||
if (now - lastLogs > 60_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'logs');
|
||||
await this.setTimestamp(logsKey, now);
|
||||
}
|
||||
|
||||
// MAC scan — switches only, user-configured interval
|
||||
if (macScanEnabled && device.device_type === 'switch') {
|
||||
const macKey = `poll:macscan:${device.id}`;
|
||||
const lastMac = await this.getTimestamp(macKey);
|
||||
if (now - lastMac > macScanInterval * 1_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'macscan');
|
||||
await this.setTimestamp(macKey, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Spectral scan — wireless_ap only, user-configured interval (default 24h)
|
||||
if (spectralEnabled && device.device_type === 'wireless_ap') {
|
||||
const spectralKey = `poll:spectral:${device.id}`;
|
||||
const lastSpectral = await this.getTimestamp(spectralKey);
|
||||
if (now - lastSpectral > spectralIntervalHours * 3_600_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'spectral');
|
||||
await this.setTimestamp(spectralKey, now);
|
||||
}
|
||||
}
|
||||
|
||||
// AP scan — wireless_ap only, user-configured interval (default 24h)
|
||||
if (apScanEnabled && device.device_type === 'wireless_ap') {
|
||||
const apScanKey = `poll:apscan:${device.id}`;
|
||||
const lastApScan = await this.getTimestamp(apScanKey);
|
||||
if (now - lastApScan > apScanIntervalHours * 3_600_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'apscan');
|
||||
await this.setTimestamp(apScanKey, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse DNS enrichment — global, runs every 5 minutes when enabled
|
||||
if (reverseDnsEnabled) {
|
||||
const rdnsKey = 'task:reverse_dns';
|
||||
const lastRdns = await this.getTimestamp(rdnsKey);
|
||||
if (now - lastRdns > 300_000) {
|
||||
await this.setTimestamp(rdnsKey, now);
|
||||
this.resolveClientHostnames().catch((e) =>
|
||||
console.error('[Poller] Reverse DNS error:', e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware update check — runs once per day
|
||||
const firmwareKey = 'task:firmware_check';
|
||||
const lastFirmware = await this.getTimestamp(firmwareKey);
|
||||
if (now - lastFirmware > 86_400_000) {
|
||||
await this.setTimestamp(firmwareKey, now);
|
||||
this.checkAllDevicesFirmware(devices).catch((e) =>
|
||||
console.error('[Poller] Firmware check error:', e)
|
||||
);
|
||||
}
|
||||
|
||||
// Stale client pruning — runs once per hour
|
||||
// Deletes inactive client records not seen for longer than retention_clients_days.
|
||||
const pruneKey = 'task:prune_clients';
|
||||
const lastPrune = await this.getTimestamp(pruneKey);
|
||||
if (now - lastPrune > 3_600_000) {
|
||||
await this.setTimestamp(pruneKey, now);
|
||||
this.pruneStaleClients(appSettings).catch((e) =>
|
||||
console.error('[Poller] Client prune error:', e)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Scheduler error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAppSettings(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const rows = await query<{ key: string; value: unknown }>(
|
||||
`SELECT key, value FROM app_settings
|
||||
WHERE key IN ('mac_scan_enabled', 'mac_scan_interval', 'reverse_dns_enabled',
|
||||
'retention_clients_days', 'spectral_scan_enabled',
|
||||
'spectral_scan_interval_hours', 'ap_scan_enabled',
|
||||
'ap_scan_interval_hours')`
|
||||
);
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const row of rows) map[row.key] = row.value;
|
||||
return map;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveClientHostnames(): Promise<void> {
|
||||
const { reverse } = await import('dns/promises');
|
||||
|
||||
const clients = await query<{ mac_address: string; ip_address: string }>(
|
||||
`SELECT DISTINCT ON (ip_address) mac_address, ip_address
|
||||
FROM clients
|
||||
WHERE ip_address IS NOT NULL AND ip_address != ''
|
||||
AND (hostname IS NULL OR hostname = '')
|
||||
ORDER BY ip_address, last_seen DESC
|
||||
LIMIT 50`
|
||||
);
|
||||
if (clients.length === 0) return;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
clients.map(async (c) => {
|
||||
const names = await reverse(c.ip_address);
|
||||
return { mac: c.mac_address, hostname: names[0] };
|
||||
})
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
await query(
|
||||
`UPDATE clients SET hostname = $1
|
||||
WHERE mac_address = $2 AND (hostname IS NULL OR hostname = '')`,
|
||||
[r.value.hostname, r.value.mac]
|
||||
);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
console.log(`[Poller] Reverse DNS enriched ${updated} client hostname(s)`);
|
||||
this.io?.emit('clients:updated', {});
|
||||
}
|
||||
}
|
||||
|
||||
private async pruneStaleClients(settings: Record<string, unknown>): Promise<void> {
|
||||
// Delete inactive clients not seen for longer than the configured retention period.
|
||||
// Default: 7 days. Preserves any client that was active within the window so
|
||||
// short-lived or intermittent devices aren't wiped prematurely.
|
||||
const retentionDays = (settings['retention_clients_days'] as number) || 7;
|
||||
const result = await query<{ count: string }>(
|
||||
`WITH deleted AS (
|
||||
DELETE FROM clients
|
||||
WHERE active = FALSE
|
||||
AND last_seen < NOW() - ($1 || ' days')::interval
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) AS count FROM deleted`,
|
||||
[retentionDays]
|
||||
);
|
||||
const count = parseInt(result[0]?.count || '0', 10);
|
||||
if (count > 0) {
|
||||
console.log(`[Poller] Pruned ${count} stale client record(s) (inactive > ${retentionDays} days)`);
|
||||
this.io?.emit('clients:updated', {});
|
||||
}
|
||||
}
|
||||
|
||||
async pruneStaleClientsNow(): Promise<number> {
|
||||
const rows = await query<{ value: unknown }>(
|
||||
`SELECT value FROM app_settings WHERE key = 'retention_clients_days'`
|
||||
);
|
||||
const retentionDays = (rows[0]?.value as number) || 7;
|
||||
const result = await query<{ count: string }>(
|
||||
`WITH deleted AS (
|
||||
DELETE FROM clients
|
||||
WHERE active = FALSE
|
||||
AND last_seen < NOW() - ($1 || ' days')::interval
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) AS count FROM deleted`,
|
||||
[retentionDays]
|
||||
);
|
||||
return parseInt(result[0]?.count || '0', 10);
|
||||
}
|
||||
|
||||
private async getTimestamp(key: string): Promise<number> {
|
||||
try {
|
||||
const { redis } = await import('../config/redis');
|
||||
const val = await redis.get(key);
|
||||
return val ? parseInt(val, 10) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async setTimestamp(key: string, ts: number): Promise<void> {
|
||||
try {
|
||||
const { redis } = await import('../config/redis');
|
||||
await redis.set(key, String(ts), 'EX', 600);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private startWorkers(): void {
|
||||
const workerOptions = {
|
||||
connection: createRedisConnection(),
|
||||
concurrency: 3,
|
||||
};
|
||||
|
||||
this.fastWorker = new Worker(
|
||||
'poll-fast',
|
||||
async (job: Job<PollJob>) => {
|
||||
await this.processPollJob(job.data);
|
||||
},
|
||||
workerOptions
|
||||
);
|
||||
|
||||
this.slowWorker = new Worker(
|
||||
'poll-slow',
|
||||
async (job: Job<PollJob>) => {
|
||||
await this.processSlowJob(job.data);
|
||||
},
|
||||
{ ...workerOptions, connection: createRedisConnection() }
|
||||
);
|
||||
|
||||
this.logsWorker = new Worker(
|
||||
'poll-logs',
|
||||
async (job: Job<PollJob>) => {
|
||||
await this.processLogsJob(job.data);
|
||||
},
|
||||
{ ...workerOptions, connection: createRedisConnection() }
|
||||
);
|
||||
|
||||
this.fastWorker.on('failed', (job, err) => {
|
||||
if (job) {
|
||||
this.handleDeviceFailure(job.data.deviceId, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async processPollJob(data: PollJob): Promise<void> {
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
const prevStatus = device.status; // capture before poll
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
|
||||
if (data.type === 'full') {
|
||||
await collector.collectAll();
|
||||
} else if (data.type === 'macscan') {
|
||||
await collector.runMacScan();
|
||||
this.io?.emit('clients:updated', { deviceId: device.id });
|
||||
return;
|
||||
} else {
|
||||
await collector.collectFast();
|
||||
}
|
||||
|
||||
// Device came online (first poll after add, or recovery from offline)
|
||||
if (prevStatus !== 'online') {
|
||||
alertService.dispatch('device_online', `${device.name} is back online`, {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
this.io?.emit('device:updated', { deviceId: device.id });
|
||||
this.io?.emit('clients:updated', { deviceId: device.id });
|
||||
} catch (err) {
|
||||
await this.handleDeviceFailure(device.id, (err as Error).message);
|
||||
throw err;
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static aggregateSpectralRows(
|
||||
rows: Record<string, string>[]
|
||||
): { freq: number; magn: number; peak: number }[] {
|
||||
const map = new Map<number, { sum: number; count: number; peak: number }>();
|
||||
for (const row of rows) {
|
||||
const freq = parseFloat(row['freq'] || '0');
|
||||
const magn = parseInt(row['magn'] || '-120', 10);
|
||||
const peak = parseInt(row['peak'] || magn.toString(), 10);
|
||||
if (freq <= 0) continue;
|
||||
const existing = map.get(freq);
|
||||
if (existing) {
|
||||
existing.sum += magn;
|
||||
existing.count += 1;
|
||||
existing.peak = Math.max(existing.peak, peak);
|
||||
} else {
|
||||
map.set(freq, { sum: magn, count: 1, peak });
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([freq, { sum, count, peak }]) => ({
|
||||
freq,
|
||||
magn: Math.round(sum / count),
|
||||
peak,
|
||||
}));
|
||||
}
|
||||
|
||||
private static aggregateAPScanRows(
|
||||
allRows: { iface: string; rows: Record<string, string>[] }[],
|
||||
lookupVendor: (mac: string) => string
|
||||
): unknown[] {
|
||||
interface BandEntry { bssid: string; vendor: string; signal: number; freq: number; band: string; channel_width: string }
|
||||
interface NetworkEntry { ssid: string; security: string; hidden: boolean; entries: BandEntry[] }
|
||||
const byKey = new Map<string, NetworkEntry>();
|
||||
|
||||
function normBand(band: string, freq: number): string {
|
||||
if (band.includes('6ghz') || freq >= 5925) return '6 GHz';
|
||||
if (band.includes('5ghz') || (freq >= 4900 && freq < 5925)) return '5 GHz';
|
||||
return '2.4 GHz';
|
||||
}
|
||||
|
||||
for (const { rows } of allRows) {
|
||||
for (const row of rows) {
|
||||
const ssid = row['network-name'] || row['ssid'] || '';
|
||||
const bssid = (row['address'] || row['bssid'] || '').toLowerCase();
|
||||
if (!bssid) continue;
|
||||
const rawSig = row['signal-strength'] || row['signal'] || '-100';
|
||||
const signal = parseInt(rawSig, 10) || -100;
|
||||
const freq = parseFloat(row['frequency'] || row['channel'] || '0');
|
||||
const band = normBand(row['band'] || row['radio-band'] || '', freq);
|
||||
const security = row['security'] || row['authentication-types'] ? (row['security'] || 'WPA') : 'open';
|
||||
const channelWidth = row['channel-width'] || '';
|
||||
const key = ssid || `hidden:${bssid}`;
|
||||
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, { ssid, security, hidden: !ssid, entries: [] });
|
||||
}
|
||||
const net = byKey.get(key)!;
|
||||
const existing = net.entries.find(e => e.bssid === bssid && e.freq === freq);
|
||||
if (existing) {
|
||||
if (signal > existing.signal) existing.signal = signal;
|
||||
} else {
|
||||
net.entries.push({ bssid, vendor: lookupVendor(bssid), signal, freq, band, channel_width: channelWidth });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byKey.values()).sort((a, b) => {
|
||||
const aBest = Math.max(...a.entries.map(e => e.signal));
|
||||
const bBest = Math.max(...b.entries.map(e => e.signal));
|
||||
return bBest - aBest;
|
||||
});
|
||||
}
|
||||
|
||||
private async processSpectralJob(data: PollJob): Promise<void> {
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
// Fetch wireless interfaces for this device so we know which radios to scan
|
||||
const ifaces = await query<{ name: string }>(
|
||||
`SELECT name FROM wireless_interfaces WHERE device_id = $1 AND disabled = FALSE`,
|
||||
[device.id]
|
||||
);
|
||||
if (ifaces.length === 0) return;
|
||||
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
for (const iface of ifaces) {
|
||||
const rows = await collector.collectSpectralScan(iface.name);
|
||||
if (rows.length === 0) continue;
|
||||
const aggregated = PollerService.aggregateSpectralRows(rows);
|
||||
await query(
|
||||
`INSERT INTO spectral_scan_data (device_id, interface_name, data, scan_type)
|
||||
VALUES ($1, $2, $3, 'scheduled')`,
|
||||
[device.id, iface.name, JSON.stringify(aggregated)]
|
||||
);
|
||||
console.log(`[Poller] Spectral scan saved for ${device.name}/${iface.name} (${aggregated.length} freq points)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Spectral scan failed for ${device.name}:`, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async processApScanJob(data: PollJob): Promise<void> {
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
const ifaces = await query<{ name: string }>(
|
||||
`SELECT name FROM wireless_interfaces WHERE device_id = $1 AND disabled = FALSE`,
|
||||
[device.id]
|
||||
);
|
||||
if (ifaces.length === 0) return;
|
||||
|
||||
const { lookupVendor } = await import('../utils/oui');
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
const allRows: { iface: string; rows: Record<string, string>[] }[] = [];
|
||||
for (const iface of ifaces) {
|
||||
const rows = await collector.scanWireless(iface.name).catch(() => [] as Record<string, string>[]);
|
||||
if (rows.length > 0) allRows.push({ iface: iface.name, rows });
|
||||
}
|
||||
if (allRows.length === 0) return;
|
||||
|
||||
const aggregated = PollerService.aggregateAPScanRows(allRows, lookupVendor);
|
||||
await query(
|
||||
`INSERT INTO ap_scan_data (device_id, data, scan_type) VALUES ($1, $2, 'scheduled')`,
|
||||
[device.id, JSON.stringify(aggregated)]
|
||||
);
|
||||
console.log(`[Poller] AP scan saved for ${device.name} (${aggregated.length} networks)`);
|
||||
} catch (err) {
|
||||
console.error(`[Poller] AP scan failed for ${device.name}:`, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async processSlowJob(data: PollJob): Promise<void> {
|
||||
if (data.type === 'spectral') {
|
||||
return this.processSpectralJob(data);
|
||||
}
|
||||
if (data.type === 'apscan') {
|
||||
return this.processApScanJob(data);
|
||||
}
|
||||
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.collectSlow();
|
||||
await collector.collectNeighbors();
|
||||
await collector.collectStp();
|
||||
this.io?.emit('device:updated', { deviceId: device.id });
|
||||
|
||||
// Fire device_discovered for any LLDP neighbors not matched to a managed device.
|
||||
// AlertService's per-cooldownKey cooldown prevents repeat alerts for the same neighbor.
|
||||
const unresolved = await query<{ neighbor_address: string; neighbor_identity: string }>(
|
||||
`SELECT DISTINCT neighbor_address, neighbor_identity
|
||||
FROM topology_links
|
||||
WHERE from_device_id = $1
|
||||
AND to_device_id IS NULL
|
||||
AND neighbor_address IS NOT NULL`,
|
||||
[device.id]
|
||||
);
|
||||
for (const nb of unresolved) {
|
||||
alertService.dispatch('device_discovered',
|
||||
`Unmanaged device discovered: ${nb.neighbor_identity || nb.neighbor_address} (${nb.neighbor_address})`,
|
||||
{
|
||||
details: nb.neighbor_identity || undefined,
|
||||
cooldownKey: `device_discovered:${nb.neighbor_address}`,
|
||||
}
|
||||
).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
await this.handleDeviceFailure(device.id, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async processLogsJob(data: PollJob): Promise<void> {
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.collectLogs();
|
||||
this.io?.emit('events:updated', { deviceId: device.id });
|
||||
|
||||
// Fire log_error / log_warning alerts if new entries appeared in the last 90s
|
||||
const recent = await query<{ severity: string; message: string }>(
|
||||
`SELECT severity, message FROM events
|
||||
WHERE device_id = $1
|
||||
AND event_time > NOW() - INTERVAL '90 seconds'
|
||||
ORDER BY event_time DESC LIMIT 1`,
|
||||
[device.id]
|
||||
);
|
||||
for (const ev of recent) {
|
||||
if (ev.severity === 'error') {
|
||||
alertService.dispatch('log_error', ev.message, {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
}).catch(() => {});
|
||||
} else if (ev.severity === 'warning') {
|
||||
alertService.dispatch('log_warning', ev.message, {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[PollerService] Log collection failed for device ${device.id} (${device.name}):`, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDeviceFailure(deviceId: number, message: string): Promise<void> {
|
||||
const device = await this.getDevice(deviceId);
|
||||
const prevStatus = device?.status;
|
||||
await query(`UPDATE devices SET status = 'offline', updated_at = NOW() WHERE id = $1`, [deviceId]);
|
||||
if (prevStatus !== 'offline') {
|
||||
alertService.dispatch('device_offline', `${device?.name ?? `Device #${deviceId}`} is offline: ${message}`, {
|
||||
deviceId,
|
||||
deviceName: device?.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
// Mark all clients for this device inactive — updateClients() never ran because connect() failed.
|
||||
await query(`UPDATE clients SET active = FALSE WHERE device_id = $1`, [deviceId]);
|
||||
// Write updated global deduped count and a per-device zero so history is continuous.
|
||||
if (device) {
|
||||
const writeApi = getWriteApi();
|
||||
writeApi.writePoint(
|
||||
new Point('client_counts')
|
||||
.tag('device_id', String(deviceId))
|
||||
.tag('device_name', device.name)
|
||||
.intField('total_clients', 0)
|
||||
.intField('wireless_clients', 0)
|
||||
.intField('wired_clients', 0)
|
||||
.timestamp(new Date())
|
||||
);
|
||||
// Recompute global deduplicated count after marking this device's clients inactive.
|
||||
const dedupedRows = await query<{ count: string }>(
|
||||
`SELECT COUNT(DISTINCT mac_address) AS count FROM clients WHERE active = TRUE`
|
||||
);
|
||||
const globalTotal = parseInt(dedupedRows[0]?.count || '0', 10);
|
||||
writeApi.writePoint(
|
||||
new Point('client_counts')
|
||||
.tag('device_id', '_global')
|
||||
.tag('device_name', '_global')
|
||||
.intField('total_clients', globalTotal)
|
||||
.timestamp(new Date())
|
||||
);
|
||||
await writeApi.flush().catch(() => {});
|
||||
}
|
||||
this.io?.emit('device:status', { deviceId, status: 'offline', message });
|
||||
this.io?.emit('clients:updated', { deviceId });
|
||||
}
|
||||
|
||||
private async checkAllDevicesFirmware(devices: DeviceRow[]): Promise<void> {
|
||||
// Only check devices that are currently online to avoid long timeouts
|
||||
const onlineDevices = devices.filter((d) => d.status === 'online');
|
||||
console.log(`[Poller] Starting firmware check for ${onlineDevices.length} online device(s)`);
|
||||
|
||||
for (const device of onlineDevices) {
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
const updateInfo = await collector.checkForUpdates();
|
||||
|
||||
const latestVersion = (updateInfo['latest-version'] ?? '').trim();
|
||||
const installedVersion = (updateInfo['installed-version'] ?? '').trim();
|
||||
const statusText = (updateInfo['status'] ?? '').toLowerCase();
|
||||
|
||||
const hasUpdate =
|
||||
statusText.includes('available') ||
|
||||
(latestVersion && installedVersion && latestVersion !== installedVersion);
|
||||
|
||||
// Read current flag before updating so we can detect first-discovery
|
||||
const current = await query<{ firmware_update_available: boolean }>(
|
||||
`SELECT firmware_update_available FROM devices WHERE id = $1`,
|
||||
[device.id]
|
||||
);
|
||||
const wasAvailable = current[0]?.firmware_update_available ?? false;
|
||||
|
||||
await query(
|
||||
`UPDATE devices
|
||||
SET firmware_update_available = $1,
|
||||
latest_ros_version = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[hasUpdate, latestVersion || null, device.id]
|
||||
);
|
||||
|
||||
if (hasUpdate) {
|
||||
this.io?.emit('device:updated', { deviceId: device.id });
|
||||
// Alert only on first discovery (not on every daily check)
|
||||
if (!wasAvailable) {
|
||||
const msg = latestVersion
|
||||
? `${device.name} has a firmware update available: ${latestVersion}`
|
||||
: `${device.name} has a firmware update available`;
|
||||
alertService.dispatch('firmware_update_available', msg, {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
details: latestVersion ? `Current: ${installedVersion} → Latest: ${latestVersion}` : undefined,
|
||||
}).catch(() => {});
|
||||
console.log(`[Poller] Firmware update detected for ${device.name}: ${installedVersion} → ${latestVersion}`);
|
||||
}
|
||||
} else if (wasAvailable) {
|
||||
// Update was installed — clear the flag and notify the UI
|
||||
this.io?.emit('device:updated', { deviceId: device.id });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Firmware check failed for ${device.name}:`, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
console.log(`[Poller] Firmware check complete`);
|
||||
}
|
||||
|
||||
private async getDevice(deviceId: number): Promise<DeviceRow | null> {
|
||||
const rows = await query<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [deviceId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,488 @@
|
||||
import * as net from 'net';
|
||||
import * as crypto from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface RouterOSSentence {
|
||||
type: string;
|
||||
words: Record<string, string>;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export class RouterOSError extends Error {
|
||||
constructor(message: string, public readonly code?: string) {
|
||||
super(message);
|
||||
this.name = 'RouterOSError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RouterOSClient extends EventEmitter {
|
||||
private socket: net.Socket | null = null;
|
||||
private buffer: Buffer = Buffer.alloc(0);
|
||||
private connected = false;
|
||||
private authenticated = false;
|
||||
|
||||
// Sequential command queue: each command waits for previous to complete
|
||||
private operationChain: Promise<void> = Promise.resolve();
|
||||
|
||||
// Pending read resolver (one outstanding read at a time)
|
||||
private pendingRead: {
|
||||
resolve: (s: RouterOSSentence) => void;
|
||||
reject: (e: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
} | null = null;
|
||||
private sentenceQueue: RouterOSSentence[] = [];
|
||||
|
||||
private tagCounter = 0;
|
||||
|
||||
constructor(
|
||||
private readonly host: string,
|
||||
private readonly port: number = 8728,
|
||||
private readonly username: string,
|
||||
private readonly password: string,
|
||||
private readonly connectTimeoutMs: number = 15000,
|
||||
private readonly readTimeoutMs: number = 30000
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new RouterOSError(`Connection timeout to ${this.host}:${this.port}`));
|
||||
this.socket?.destroy();
|
||||
}, this.connectTimeoutMs);
|
||||
|
||||
this.socket = net.createConnection({ host: this.host, port: this.port });
|
||||
|
||||
this.socket.on('connect', async () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
await this.authenticate();
|
||||
this.connected = true;
|
||||
this.authenticated = true;
|
||||
resolve();
|
||||
} catch (err) {
|
||||
this.socket?.destroy();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
this.buffer = Buffer.concat([this.buffer, data]);
|
||||
this.processBuffer();
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
if (this.pendingRead) {
|
||||
this.pendingRead.reject(new RouterOSError(`Socket error: ${err.message}`));
|
||||
this.pendingRead = null;
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
if (this.pendingRead) {
|
||||
this.pendingRead.reject(new RouterOSError('Connection closed'));
|
||||
this.pendingRead = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Enqueue a command so concurrent calls don't interleave
|
||||
async execute(
|
||||
command: string,
|
||||
params: Record<string, string> = {},
|
||||
queries: string[] = []
|
||||
): Promise<Record<string, string>[]> {
|
||||
let result!: Record<string, string>[];
|
||||
let error!: Error;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.operationChain = this.operationChain.then(async () => {
|
||||
try {
|
||||
result = await this._executeRaw(command, params, queries);
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _executeRaw(
|
||||
command: string,
|
||||
params: Record<string, string>,
|
||||
queries: string[]
|
||||
): Promise<Record<string, string>[]> {
|
||||
if (!this.socket || !this.connected) {
|
||||
throw new RouterOSError('Not connected');
|
||||
}
|
||||
|
||||
const words = [command];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
words.push(`=${key}=${value}`);
|
||||
}
|
||||
for (const q of queries) {
|
||||
words.push(q);
|
||||
}
|
||||
|
||||
await this.sendSentence(words);
|
||||
|
||||
const results: Record<string, string>[] = [];
|
||||
while (true) {
|
||||
const sentence = await this.readNextSentence();
|
||||
if (sentence.type === '!done') break;
|
||||
if (sentence.type === '!re') {
|
||||
results.push(sentence.words);
|
||||
} else if (sentence.type === '!trap') {
|
||||
// RouterOS always sends !done after !trap — drain it to keep the stream in sync.
|
||||
// Without this, the stale !done is consumed by the next command, causing it to
|
||||
// exit immediately with empty results before its real response arrives.
|
||||
await this.readNextSentence().catch(() => {});
|
||||
throw new RouterOSError(
|
||||
sentence.words['message'] || `Command failed: ${command}`,
|
||||
sentence.words['category']
|
||||
);
|
||||
} else if (sentence.type === '!fatal') {
|
||||
this.connected = false;
|
||||
throw new RouterOSError(sentence.words['message'] || 'Fatal error received');
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.connected = false;
|
||||
this.authenticated = false;
|
||||
this.socket?.destroy();
|
||||
this.socket = null;
|
||||
this.buffer = Buffer.alloc(0);
|
||||
this.sentenceQueue = [];
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.authenticated;
|
||||
}
|
||||
|
||||
// ─── RouterOS API Protocol ────────────────────────────────────────────────
|
||||
|
||||
private async authenticate(): Promise<void> {
|
||||
// Try plain-text login (RouterOS 6.49+ and RouterOS 7.x)
|
||||
await this.sendSentence(['/login', `=name=${this.username}`, `=password=${this.password}`]);
|
||||
const first = await this.readNextSentence();
|
||||
|
||||
if (first.type === '!done') {
|
||||
return; // v7 / newer v6 plain-text login succeeded
|
||||
}
|
||||
|
||||
if (first.type === '!re' && first.words['ret']) {
|
||||
// Legacy v6 challenge-response
|
||||
const challengeHex = first.words['ret'];
|
||||
// Drain the !done that follows the !re
|
||||
await this.readNextSentence();
|
||||
|
||||
const challenge = Buffer.from(challengeHex, 'hex');
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(Buffer.from([0x00]));
|
||||
md5.update(Buffer.from(this.password, 'utf8'));
|
||||
md5.update(challenge);
|
||||
const responseHex = '00' + md5.digest('hex');
|
||||
|
||||
await this.sendSentence([
|
||||
'/login',
|
||||
`=name=${this.username}`,
|
||||
`=response=${responseHex}`,
|
||||
]);
|
||||
const result = await this.readNextSentence();
|
||||
if (result.type !== '!done') {
|
||||
throw new RouterOSError(
|
||||
result.words['message'] || 'Authentication failed (legacy MD5)',
|
||||
result.words['category']
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.type === '!trap') {
|
||||
throw new RouterOSError(
|
||||
first.words['message'] || 'Authentication failed',
|
||||
first.words['category']
|
||||
);
|
||||
}
|
||||
|
||||
throw new RouterOSError(`Unexpected login response: ${first.type}`);
|
||||
}
|
||||
|
||||
private encodeLength(len: number): Buffer {
|
||||
if (len < 0x80) {
|
||||
return Buffer.from([len]);
|
||||
} else if (len < 0x4000) {
|
||||
return Buffer.from([(len >> 8) | 0x80, len & 0xff]);
|
||||
} else if (len < 0x200000) {
|
||||
return Buffer.from([(len >> 16) | 0xc0, (len >> 8) & 0xff, len & 0xff]);
|
||||
} else if (len < 0x10000000) {
|
||||
return Buffer.from([
|
||||
(len >> 24) | 0xe0,
|
||||
(len >> 16) & 0xff,
|
||||
(len >> 8) & 0xff,
|
||||
len & 0xff,
|
||||
]);
|
||||
} else {
|
||||
return Buffer.from([
|
||||
0xf0,
|
||||
(len >> 24) & 0xff,
|
||||
(len >> 16) & 0xff,
|
||||
(len >> 8) & 0xff,
|
||||
len & 0xff,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeLength(
|
||||
buf: Buffer,
|
||||
offset: number
|
||||
): { length: number; bytesConsumed: number } | null {
|
||||
if (offset >= buf.length) return null;
|
||||
const b = buf[offset];
|
||||
|
||||
if (b < 0x80) {
|
||||
return { length: b, bytesConsumed: 1 };
|
||||
} else if (b < 0xc0) {
|
||||
if (offset + 1 >= buf.length) return null;
|
||||
return {
|
||||
length: ((b & 0x3f) << 8) | buf[offset + 1],
|
||||
bytesConsumed: 2,
|
||||
};
|
||||
} else if (b < 0xe0) {
|
||||
if (offset + 2 >= buf.length) return null;
|
||||
return {
|
||||
length: ((b & 0x1f) << 16) | (buf[offset + 1] << 8) | buf[offset + 2],
|
||||
bytesConsumed: 3,
|
||||
};
|
||||
} else if (b < 0xf0) {
|
||||
if (offset + 3 >= buf.length) return null;
|
||||
return {
|
||||
length:
|
||||
((b & 0x0f) << 24) |
|
||||
(buf[offset + 1] << 16) |
|
||||
(buf[offset + 2] << 8) |
|
||||
buf[offset + 3],
|
||||
bytesConsumed: 4,
|
||||
};
|
||||
} else {
|
||||
if (offset + 4 >= buf.length) return null;
|
||||
return {
|
||||
length:
|
||||
(buf[offset + 1] << 24) |
|
||||
(buf[offset + 2] << 16) |
|
||||
(buf[offset + 3] << 8) |
|
||||
buf[offset + 4],
|
||||
bytesConsumed: 5,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async sendSentence(words: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket) return reject(new RouterOSError('Socket not available'));
|
||||
const parts: Buffer[] = [];
|
||||
for (const word of words) {
|
||||
const wb = Buffer.from(word, 'utf8');
|
||||
parts.push(this.encodeLength(wb.length), wb);
|
||||
}
|
||||
parts.push(Buffer.from([0x00])); // end-of-sentence
|
||||
this.socket.write(Buffer.concat(parts), (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private processBuffer(): void {
|
||||
while (true) {
|
||||
const sentence = this.tryParseSentence();
|
||||
if (!sentence) break;
|
||||
|
||||
if (this.pendingRead) {
|
||||
clearTimeout(this.pendingRead.timeout);
|
||||
const { resolve } = this.pendingRead;
|
||||
this.pendingRead = null;
|
||||
resolve(sentence);
|
||||
} else {
|
||||
this.sentenceQueue.push(sentence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tryParseSentence(): RouterOSSentence | null {
|
||||
const words: string[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < this.buffer.length) {
|
||||
const dec = this.decodeLength(this.buffer, offset);
|
||||
if (!dec) return null; // need more bytes
|
||||
|
||||
const { length, bytesConsumed } = dec;
|
||||
|
||||
if (length === 0) {
|
||||
// End of sentence — consume it
|
||||
this.buffer = this.buffer.slice(offset + 1);
|
||||
|
||||
if (words.length === 0) return null;
|
||||
|
||||
const type = words[0];
|
||||
const parsed: Record<string, string> = {};
|
||||
let tag: string | undefined;
|
||||
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const w = words[i];
|
||||
if (w.startsWith('=')) {
|
||||
const eq = w.indexOf('=', 1);
|
||||
if (eq > 0) parsed[w.slice(1, eq)] = w.slice(eq + 1);
|
||||
} else if (w.startsWith('.tag=')) {
|
||||
tag = w.slice(5);
|
||||
}
|
||||
}
|
||||
|
||||
return { type, words: parsed, tag };
|
||||
}
|
||||
|
||||
if (offset + bytesConsumed + length > this.buffer.length) {
|
||||
return null; // need more bytes
|
||||
}
|
||||
|
||||
words.push(
|
||||
this.buffer
|
||||
.slice(offset + bytesConsumed, offset + bytesConsumed + length)
|
||||
.toString('utf8')
|
||||
);
|
||||
offset += bytesConsumed + length;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readNextSentence(timeoutMs = this.readTimeoutMs): Promise<RouterOSSentence> {
|
||||
// Check queue first
|
||||
if (this.sentenceQueue.length > 0) {
|
||||
return Promise.resolve(this.sentenceQueue.shift()!);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRead = null;
|
||||
reject(new RouterOSError('Read timeout waiting for API response'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRead = {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: timer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Execute a streaming/generator command (one that never sends !done on its own,
|
||||
// e.g. /tool/ip-scan). Collects !re rows until maxDurationMs elapses, then
|
||||
// sends /cancel to terminate the command and drains the cleanup response.
|
||||
async executeStreaming(
|
||||
command: string,
|
||||
params: Record<string, string> = {},
|
||||
maxDurationMs = 30_000
|
||||
): Promise<Record<string, string>[]> {
|
||||
let result!: Record<string, string>[];
|
||||
let error!: Error;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.operationChain = this.operationChain.then(async () => {
|
||||
try {
|
||||
result = await this._executeStreamingRaw(command, params, maxDurationMs);
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _executeStreamingRaw(
|
||||
command: string,
|
||||
params: Record<string, string>,
|
||||
maxDurationMs: number
|
||||
): Promise<Record<string, string>[]> {
|
||||
if (!this.socket || !this.connected) {
|
||||
throw new RouterOSError('Not connected');
|
||||
}
|
||||
|
||||
const tag = String(++this.tagCounter);
|
||||
const words = [command];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
words.push(`=${key}=${value}`);
|
||||
}
|
||||
words.push(`.tag=${tag}`);
|
||||
|
||||
await this.sendSentence(words);
|
||||
|
||||
const results: Record<string, string>[] = [];
|
||||
const deadline = Date.now() + maxDurationMs;
|
||||
|
||||
// Collect !re sentences until deadline or until !done / !fatal arrives
|
||||
while (true) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
|
||||
let sentence: RouterOSSentence;
|
||||
try {
|
||||
sentence = await this.readNextSentence(Math.min(remaining, this.readTimeoutMs));
|
||||
} catch {
|
||||
// Timeout or socket error — fall through to cancel
|
||||
break;
|
||||
}
|
||||
|
||||
if (sentence.type === '!re') {
|
||||
results.push(sentence.words);
|
||||
} else if (sentence.type === '!done') {
|
||||
// Command finished on its own (shouldn't happen for generators, but handle it)
|
||||
return results;
|
||||
} else if (sentence.type === '!trap') {
|
||||
// Drain the !done that follows !trap
|
||||
await this.readNextSentence(5_000).catch(() => {});
|
||||
throw new RouterOSError(
|
||||
sentence.words['message'] || `Command failed: ${command}`,
|
||||
sentence.words['category']
|
||||
);
|
||||
} else if (sentence.type === '!fatal') {
|
||||
this.connected = false;
|
||||
throw new RouterOSError(sentence.words['message'] || 'Fatal error received');
|
||||
}
|
||||
}
|
||||
|
||||
// Send /cancel and drain responses (RouterOS replies !trap + !done to cancel)
|
||||
try {
|
||||
await this.sendSentence(['/cancel', `=tag=${tag}`]);
|
||||
// Drain up to 2 sentences (!trap and/or !done) with a short timeout each
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const s = await this.readNextSentence(5_000).catch(() => null);
|
||||
if (!s || s.type === '!done') break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const key = process.env.ENCRYPTION_KEY || 'defaultkey32byteslongencryptkey!';
|
||||
// Ensure exactly 32 bytes via SHA-256 hash if needed
|
||||
if (key.length === 32) {
|
||||
return Buffer.from(key, 'utf8');
|
||||
}
|
||||
return crypto.createHash('sha256').update(key).digest();
|
||||
}
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const key = getKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// Format: iv:tag:encrypted (all hex)
|
||||
return [iv.toString('hex'), tag.toString('hex'), encrypted.toString('hex')].join(':');
|
||||
}
|
||||
|
||||
export function decrypt(encryptedText: string): string {
|
||||
const key = getKey();
|
||||
const parts = encryptedText.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted text format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const tag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = Buffer.from(parts[2], 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Local IEEE OUI database lookup.
|
||||
* Downloads the official IEEE MA-L CSV on first use, caches it for 30 days,
|
||||
* then serves all vendor lookups from memory — no rate limits, no external calls
|
||||
* per client.
|
||||
*/
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
const CACHE_FILE = '/tmp/oui-ieee.json';
|
||||
const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const IEEE_CSV_URL = 'https://standards-oui.ieee.org/oui/oui.csv';
|
||||
|
||||
let db: Map<string, string> | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
export function initOuiDatabase(): Promise<void> {
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = _load();
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function lookupVendor(mac: string): string {
|
||||
if (!db) return '';
|
||||
const oui = mac.replace(/[:\-.]/g, '').substring(0, 6).toUpperCase();
|
||||
return db.get(oui) ?? '';
|
||||
}
|
||||
|
||||
async function _load(): Promise<void> {
|
||||
// Try valid cache first
|
||||
try {
|
||||
const stat = fs.statSync(CACHE_FILE);
|
||||
if (Date.now() - stat.mtimeMs < CACHE_MAX_AGE_MS) {
|
||||
const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as Record<string, string>;
|
||||
const map = new Map(Object.entries(raw));
|
||||
if (map.size > 10_000) {
|
||||
db = map;
|
||||
console.log(`OUI database loaded from cache: ${db.size} entries`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* cache miss */ }
|
||||
|
||||
// Download fresh from IEEE
|
||||
try {
|
||||
console.log('Downloading IEEE OUI database…');
|
||||
const csv = await _fetch(IEEE_CSV_URL);
|
||||
const map = _parseCsv(csv);
|
||||
if (map.size > 10_000) {
|
||||
db = map;
|
||||
console.log(`OUI database downloaded: ${db.size} entries`);
|
||||
// Persist cache
|
||||
const obj: Record<string, string> = {};
|
||||
for (const [k, v] of map) obj[k] = v;
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(obj));
|
||||
} else {
|
||||
console.warn('OUI: Downloaded file too small, ignoring');
|
||||
db = new Map();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`OUI: Download failed (${(err as Error).message}). Vendor lookups will be empty.`);
|
||||
db = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function _parseCsv(csv: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of csv.split('\n')) {
|
||||
// IEEE CSV: Registry,Assignment,Organization Name,Organization Address
|
||||
// Quoted fields may contain commas, e.g. "Cisco Systems, Inc"
|
||||
const fields = _csvSplit(line);
|
||||
if (fields[0]?.trim() !== 'MA-L') continue;
|
||||
const oui = fields[1]?.trim().toUpperCase();
|
||||
const name = fields[2]?.trim();
|
||||
if (oui?.length === 6 && name) map.set(oui, name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function _csvSplit(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let cur = '';
|
||||
let inQuote = false;
|
||||
for (const ch of line) {
|
||||
if (ch === '"') { inQuote = !inQuote; }
|
||||
else if (ch === ',' && !inQuote) { fields.push(cur); cur = ''; }
|
||||
else { cur += ch; }
|
||||
}
|
||||
fields.push(cur);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function _fetch(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { timeout: 60_000 }, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Read the backend server's kernel ARP cache from /proc/net/arp.
|
||||
* Returns a MAC → IPv4 map of all currently cached entries.
|
||||
* Works on Linux; on macOS Docker Desktop the container is NATted so
|
||||
* this will only show Docker-internal bridge entries.
|
||||
*/
|
||||
async function readKernelArpCache(): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {};
|
||||
try {
|
||||
const content = await readFile('/proc/net/arp', 'utf8');
|
||||
for (const line of content.split('\n').slice(1)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
// Format: IP HW-type Flags HW-address Mask Device
|
||||
if (parts.length >= 4) {
|
||||
const ip = parts[0];
|
||||
const mac = parts[3].toLowerCase();
|
||||
if (ip && mac && mac !== '00:00:00:00:00:00' && ip !== '0.0.0.0') {
|
||||
map[mac] = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /proc/net/arp not available (non-Linux or permission denied)
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run arp-scan on all local network interfaces to actively discover
|
||||
* MAC → IPv4 mappings on the same subnet as the backend container.
|
||||
*
|
||||
* Requires: arp-scan installed + NET_RAW capability (set via docker-compose
|
||||
* cap_add / setcap on the binary). Fails gracefully if unavailable.
|
||||
*
|
||||
* Note: On macOS Docker Desktop the container is NATted inside a Linux VM,
|
||||
* so arp-scan will only see other Docker containers, NOT physical network hosts.
|
||||
* On a Linux host with bridge or macvlan Docker networking this WILL find
|
||||
* devices on the same physical subnet.
|
||||
*/
|
||||
async function runArpScan(): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {};
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'arp-scan',
|
||||
['--localnet', '--quiet', '--retry=2'],
|
||||
{ timeout: 12000 }
|
||||
);
|
||||
for (const line of stdout.split('\n')) {
|
||||
// Output lines: "<IP>\t<MAC>\t<description>"
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2 && /^\d+\.\d+\.\d+\.\d+$/.test(parts[0])) {
|
||||
const ip = parts[0];
|
||||
const mac = parts[1].toLowerCase();
|
||||
if (ip && mac) map[mac] = ip;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// arp-scan not installed, lacks permissions, or timed out
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a combined MAC → IPv4 map using the server's own ARP data.
|
||||
* Called once per collectNeighbors() run to avoid re-scanning per neighbour.
|
||||
*/
|
||||
export async function buildServerArpMap(): Promise<Record<string, string>> {
|
||||
const [passive, active] = await Promise.all([
|
||||
readKernelArpCache(),
|
||||
runArpScan(),
|
||||
]);
|
||||
// Passive cache takes precedence over scan (it's live kernel state)
|
||||
return { ...active, ...passive };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: nginx/Dockerfile
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- certs_data:/certs
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://mikrotik:${DB_PASSWORD:-mikrotik_secure_pw}@postgres:5432/mikrotik_manager
|
||||
REDIS_URL: redis://redis:6379
|
||||
INFLUXDB_URL: http://influxdb:8086
|
||||
INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-mytoken123456789}
|
||||
INFLUXDB_ORG: ${INFLUXDB_ORG:-mikrotik-manager}
|
||||
INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics}
|
||||
JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey}
|
||||
PORT: 3001
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
influxdb:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- backups_data:/app/backups
|
||||
- certs_data:/certs
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/api/health"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: mikrotik_manager
|
||||
POSTGRES_USER: mikrotik
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-mikrotik_secure_pw}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mikrotik -d mikrotik_manager"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
influxdb:
|
||||
image: influxdb:2.7
|
||||
environment:
|
||||
DOCKER_INFLUXDB_INIT_MODE: setup
|
||||
DOCKER_INFLUXDB_INIT_USERNAME: admin
|
||||
DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUXDB_ADMIN_PASSWORD:-admin_password_123}
|
||||
DOCKER_INFLUXDB_INIT_ORG: ${INFLUXDB_ORG:-mikrotik-manager}
|
||||
DOCKER_INFLUXDB_INIT_BUCKET: ${INFLUXDB_BUCKET:-metrics}
|
||||
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUXDB_TOKEN:-mytoken123456789}
|
||||
volumes:
|
||||
- influxdb_data:/var/lib/influxdb2
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
healthcheck:
|
||||
test: ["CMD", "influx", "ping"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
influxdb_data:
|
||||
backups_data:
|
||||
certs_data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mikrotik Manager</title>
|
||||
<meta name="description" content="Self-hosted Mikrotik network management platform" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.17.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.0.4",
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.309.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"recharts": "^2.10.3",
|
||||
"socket.io-client": "^4.7.3",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#2563eb"/>
|
||||
<path d="M6 16h4M22 16h4M10 16a6 6 0 0 1 12 0" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="16" r="3" fill="white"/>
|
||||
<circle cx="8" cy="16" r="2" fill="#93c5fd"/>
|
||||
<circle cx="24" cy="16" r="2" fill="#93c5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
@@ -0,0 +1,73 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import AppLayout from './components/layout/AppLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import DevicesPage from './pages/DevicesPage';
|
||||
import DeviceDetailPage from './pages/DeviceDetailPage';
|
||||
import ClientsPage from './pages/ClientsPage';
|
||||
import EventsPage from './pages/EventsPage';
|
||||
import TopologyPage from './pages/TopologyPage';
|
||||
import BackupsPage from './pages/BackupsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import SwitchesOverviewPage from './pages/SwitchesOverviewPage';
|
||||
import SwitchesSettingsPage from './pages/SwitchesSettingsPage';
|
||||
import RoutersPage from './pages/RoutersPage';
|
||||
import RouterSettingsPage from './pages/RouterSettingsPage';
|
||||
import WirelessPage from './pages/WirelessPage';
|
||||
import WirelessSettingsPage from './pages/WirelessSettingsPage';
|
||||
import WirelessClientsPage from './pages/WirelessClientsPage';
|
||||
import ClientDetailPage from './pages/ClientDetailPage';
|
||||
import NetworkServicesOverviewPage from './pages/NetworkServicesOverviewPage';
|
||||
import NetworkServicesDHCPPage from './pages/NetworkServicesDHCPPage';
|
||||
import NetworkServicesDNSPage from './pages/NetworkServicesDNSPage';
|
||||
import NetworkServicesNTPPage from './pages/NetworkServicesNTPPage';
|
||||
import NetworkServicesWireGuardPage from './pages/NetworkServicesWireGuardPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
if (!token) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="devices" element={<DevicesPage />} />
|
||||
<Route path="devices/:id" element={<DeviceDetailPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="clients/:mac" element={<ClientDetailPage />} />
|
||||
<Route path="events" element={<EventsPage />} />
|
||||
<Route path="topology" element={<TopologyPage />} />
|
||||
<Route path="backups" element={<BackupsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="switches" element={<SwitchesOverviewPage />} />
|
||||
<Route path="switches/settings" element={<SwitchesSettingsPage />} />
|
||||
<Route path="routers" element={<RoutersPage />} />
|
||||
<Route path="routers/settings" element={<RouterSettingsPage />} />
|
||||
<Route path="wireless" element={<WirelessPage />} />
|
||||
<Route path="wireless/settings" element={<WirelessSettingsPage />} />
|
||||
<Route path="wireless/clients" element={<WirelessClientsPage />} />
|
||||
<Route path="network-services" element={<NetworkServicesOverviewPage />} />
|
||||
<Route path="network-services/dhcp" element={<NetworkServicesDHCPPage />} />
|
||||
<Route path="network-services/dns" element={<NetworkServicesDNSPage />} />
|
||||
<Route path="network-services/ntp" element={<NetworkServicesNTPPage />} />
|
||||
<Route path="network-services/wireguard" element={<NetworkServicesWireGuardPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Point { x: number; y: number }
|
||||
interface Seg { from: Point; to: Point }
|
||||
interface Trace { segs: Seg[]; bright: boolean }
|
||||
interface Pulse {
|
||||
trace: Trace;
|
||||
segIdx: number;
|
||||
t: number; // [0,1] within current segment
|
||||
speed: number; // segments per second
|
||||
hue: number; // HSL hue
|
||||
trail: Point[];
|
||||
}
|
||||
|
||||
interface ThemePalette {
|
||||
bg: string;
|
||||
traceBright: string;
|
||||
traceDim: string;
|
||||
padBright: string;
|
||||
padDim: string;
|
||||
viaStroke: string;
|
||||
viaFill: string;
|
||||
pulseHues: number[];
|
||||
trailLightness: number; // % lightness for trail hsla
|
||||
glowLightness: number; // % lightness for glow center
|
||||
coreLightness: number; // % lightness for core dot
|
||||
coreSaturation: number; // % saturation for core dot
|
||||
}
|
||||
|
||||
// ─── Theme palettes ───────────────────────────────────────────────────────────
|
||||
|
||||
const DARK_PALETTE: ThemePalette = {
|
||||
bg: '#040c07',
|
||||
traceBright: '#112b1a',
|
||||
traceDim: '#0a1f10',
|
||||
padBright: '#183322',
|
||||
padDim: '#0f2219',
|
||||
viaStroke: '#1a3d25',
|
||||
viaFill: '#0d2218',
|
||||
pulseHues: [140, 150, 155, 160, 165, 170, 175, 180, 185],
|
||||
trailLightness: 65,
|
||||
glowLightness: 80,
|
||||
coreLightness: 96,
|
||||
coreSaturation: 60,
|
||||
};
|
||||
|
||||
const LIGHT_PALETTE: ThemePalette = {
|
||||
bg: '#e8f2f7',
|
||||
traceBright: '#7aafc8',
|
||||
traceDim: '#9dc4d8',
|
||||
padBright: '#689fbc',
|
||||
padDim: '#88b8cc',
|
||||
viaStroke: '#5590ac',
|
||||
viaFill: '#c2dce8',
|
||||
pulseHues: [185, 190, 195, 200, 205, 210, 215, 220, 170],
|
||||
trailLightness: 38,
|
||||
glowLightness: 28,
|
||||
coreLightness: 22,
|
||||
coreSaturation: 75,
|
||||
};
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CELL = 56;
|
||||
const TRAIL = 28;
|
||||
const PULSE_COUNT = 22;
|
||||
const DX = [1, 0, -1, 0];
|
||||
const DY = [0, 1, 0, -1];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function pt(c: number, r: number): Point {
|
||||
return { x: c * CELL, y: r * CELL };
|
||||
}
|
||||
|
||||
function buildTraces(w: number, h: number): Trace[] {
|
||||
const cols = Math.floor(w / CELL) + 2;
|
||||
const rows = Math.floor(h / CELL) + 2;
|
||||
const count = 40 + Math.floor((w * h) / 40000);
|
||||
const traces: Trace[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let c = Math.floor(Math.random() * cols);
|
||||
let r = Math.floor(Math.random() * rows);
|
||||
let dir = Math.floor(Math.random() * 4);
|
||||
const bright = Math.random() < 0.35;
|
||||
const maxLen = bright
|
||||
? 6 + Math.floor(Math.random() * 14)
|
||||
: 3 + Math.floor(Math.random() * 8);
|
||||
const segs: Seg[] = [];
|
||||
|
||||
for (let step = 0; step < maxLen; step++) {
|
||||
const nc = c + DX[dir];
|
||||
const nr = r + DY[dir];
|
||||
if (nc < 0 || nc >= cols || nr < 0 || nr >= rows) break;
|
||||
segs.push({ from: pt(c, r), to: pt(nc, nr) });
|
||||
c = nc;
|
||||
r = nr;
|
||||
if (Math.random() < 0.28) {
|
||||
dir = (dir + (Math.random() < 0.5 ? 1 : 3)) % 4;
|
||||
}
|
||||
if (step >= 2 && Math.random() < 0.06) break;
|
||||
}
|
||||
|
||||
if (segs.length >= 2) traces.push({ segs, bright });
|
||||
}
|
||||
|
||||
return traces;
|
||||
}
|
||||
|
||||
function buildPulses(traces: Trace[], palette: ThemePalette): Pulse[] {
|
||||
const candidates = traces.filter((t) => t.segs.length >= 3 && t.bright);
|
||||
const pool = candidates.length > 0 ? candidates : traces.filter((t) => t.segs.length >= 3);
|
||||
const pulses: Pulse[] = [];
|
||||
|
||||
for (let i = 0; i < PULSE_COUNT; i++) {
|
||||
const trace = pool[Math.floor(Math.random() * pool.length)];
|
||||
if (!trace) continue;
|
||||
pulses.push({
|
||||
trace,
|
||||
segIdx: Math.floor(Math.random() * trace.segs.length),
|
||||
t: Math.random(),
|
||||
speed: 0.45 + Math.random() * 0.85,
|
||||
hue: palette.pulseHues[Math.floor(Math.random() * palette.pulseHues.length)],
|
||||
trail: [],
|
||||
});
|
||||
}
|
||||
return pulses;
|
||||
}
|
||||
|
||||
function lerp(a: Point, b: Point, t: number): Point {
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
theme?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export default function CircuitBackground({ theme = 'dark' }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<{ traces: Trace[]; pulses: Pulse[]; raf: number }>({
|
||||
traces: [], pulses: [], raf: 0,
|
||||
});
|
||||
|
||||
const palette = theme === 'light' ? LIGHT_PALETTE : DARK_PALETTE;
|
||||
|
||||
const init = useCallback((w: number, h: number) => {
|
||||
const traces = buildTraces(w, h);
|
||||
const pulses = buildPulses(traces, palette);
|
||||
stateRef.current.traces = traces;
|
||||
stateRef.current.pulses = pulses;
|
||||
}, [palette]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
if (!ctx) return;
|
||||
|
||||
let lastTime = performance.now();
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
init(canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// ── Draw helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function drawTraces(traces: Trace[]) {
|
||||
for (const trace of traces) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = trace.bright ? palette.traceBright : palette.traceDim;
|
||||
ctx.lineWidth = trace.bright ? 1.5 : 1;
|
||||
ctx.lineCap = 'square';
|
||||
ctx.moveTo(trace.segs[0].from.x, trace.segs[0].from.y);
|
||||
for (const seg of trace.segs) {
|
||||
ctx.lineTo(seg.to.x, seg.to.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const padCol = trace.bright ? palette.padBright : palette.padDim;
|
||||
for (const seg of trace.segs) {
|
||||
ctx.fillStyle = padCol;
|
||||
ctx.beginPath();
|
||||
ctx.arc(seg.to.x, seg.to.y, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (trace.bright && Math.random() < 0.4) {
|
||||
const last = trace.segs[trace.segs.length - 1].to;
|
||||
ctx.strokeStyle = palette.viaStroke;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(last.x, last.y, 5, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = palette.viaFill;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawPulse(pulse: Pulse) {
|
||||
const len = pulse.trail.length;
|
||||
if (len < 2) return;
|
||||
|
||||
const { trailLightness, glowLightness, coreLightness, coreSaturation } = palette;
|
||||
|
||||
for (let i = 1; i < len; i++) {
|
||||
const alpha = Math.pow(i / len, 1.6) * 0.75;
|
||||
const width = (i / len) * 2.5;
|
||||
ctx.strokeStyle = `hsla(${pulse.hue}, 90%, ${trailLightness}%, ${alpha})`;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pulse.trail[i - 1].x, pulse.trail[i - 1].y);
|
||||
ctx.lineTo(pulse.trail[i].x, pulse.trail[i].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const head = pulse.trail[len - 1];
|
||||
const glow = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 16);
|
||||
glow.addColorStop(0, `hsla(${pulse.hue}, 90%, ${glowLightness}%, 0.7)`);
|
||||
glow.addColorStop(0.35, `hsla(${pulse.hue}, 90%, ${trailLightness}%, 0.25)`);
|
||||
glow.addColorStop(1, `hsla(${pulse.hue}, 90%, ${trailLightness}%, 0)`);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, 16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = `hsl(${pulse.hue}, ${coreSaturation}%, ${coreLightness}%)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, 2.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Animation loop ────────────────────────────────────────────────────────
|
||||
|
||||
const animate = (now: number) => {
|
||||
const dt = Math.min((now - lastTime) / 1000, 0.05);
|
||||
lastTime = now;
|
||||
|
||||
const { traces, pulses } = stateRef.current;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.fillStyle = palette.bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
drawTraces(traces);
|
||||
|
||||
for (const pulse of pulses) {
|
||||
pulse.t += pulse.speed * dt;
|
||||
while (pulse.t >= 1) {
|
||||
pulse.t -= 1;
|
||||
pulse.segIdx = (pulse.segIdx + 1) % pulse.trace.segs.length;
|
||||
}
|
||||
|
||||
const seg = pulse.trace.segs[pulse.segIdx];
|
||||
const pos = lerp(seg.from, seg.to, pulse.t);
|
||||
pulse.trail.push(pos);
|
||||
if (pulse.trail.length > TRAIL) pulse.trail.shift();
|
||||
|
||||
drawPulse(pulse);
|
||||
}
|
||||
|
||||
stateRef.current.raf = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
stateRef.current.raf = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(stateRef.current.raf);
|
||||
};
|
||||
}, [init, palette]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { X, Maximize2, Minimize2, TerminalSquare, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const MIN_W = 480;
|
||||
const MIN_H = 300;
|
||||
const DEFAULT_W = 900;
|
||||
const DEFAULT_H = 560;
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
deviceName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ConnState = 'connecting' | 'ready' | 'error' | 'closed';
|
||||
|
||||
const RESIZE_HANDLES = [
|
||||
{ dir: 'n', cursor: 'ns-resize', style: { top: 0, left: 6, right: 6, height: 6 } },
|
||||
{ dir: 'ne', cursor: 'nesw-resize', style: { top: 0, right: 0, width: 10, height: 10 } },
|
||||
{ dir: 'e', cursor: 'ew-resize', style: { top: 6, right: 0, bottom: 6, width: 6 } },
|
||||
{ dir: 'se', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: 10, height: 10 } },
|
||||
{ dir: 's', cursor: 'ns-resize', style: { bottom: 0, left: 6, right: 6, height: 6 } },
|
||||
{ dir: 'sw', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: 10, height: 10 } },
|
||||
{ dir: 'w', cursor: 'ew-resize', style: { top: 6, left: 0, bottom: 6, width: 6 } },
|
||||
{ dir: 'nw', cursor: 'nwse-resize', style: { top: 0, left: 0, width: 10, height: 10 } },
|
||||
] as const;
|
||||
|
||||
export default function TerminalModal({ deviceId, deviceName, onClose }: Props) {
|
||||
const termRef = useRef<HTMLDivElement>(null);
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const [connState, setConnState] = useState<ConnState>('connecting');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
const [pos, setPos] = useState(() => ({
|
||||
x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
||||
y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
||||
}));
|
||||
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
||||
const savedGeometry = useRef({ pos: { x: 0, y: 0 }, size: { w: DEFAULT_W, h: DEFAULT_H } });
|
||||
|
||||
const token = useAuthStore((s) => s.token);
|
||||
|
||||
// ─── SSH connection ────────────────────────────────────────────────────────
|
||||
const connect = useCallback(() => {
|
||||
socketRef.current?.disconnect();
|
||||
xtermRef.current?.clear();
|
||||
setConnState('connecting');
|
||||
setErrorMsg('');
|
||||
|
||||
const socket = io('/terminal', {
|
||||
path: '/socket.io',
|
||||
auth: { token },
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
const fit = fitRef.current;
|
||||
fit?.fit();
|
||||
socket.emit('start', {
|
||||
deviceId,
|
||||
cols: xtermRef.current?.cols ?? 80,
|
||||
rows: xtermRef.current?.rows ?? 24,
|
||||
});
|
||||
});
|
||||
socket.on('ready', () => { setConnState('ready'); xtermRef.current?.focus(); });
|
||||
socket.on('data', (d: string) => { xtermRef.current?.write(d); });
|
||||
socket.on('close', () => { setConnState('closed'); xtermRef.current?.write('\r\n\x1b[33m[Session closed]\x1b[0m\r\n'); });
|
||||
socket.on('error', (msg: string)=> { setConnState('error'); setErrorMsg(msg); });
|
||||
socket.on('connect_error',(err) => { setConnState('error'); setErrorMsg(err.message); });
|
||||
socket.on('disconnect', () => { if (connState === 'ready') setConnState('closed'); });
|
||||
}, [deviceId, token]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Terminal init (once) ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!termRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Menlo, Monaco, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
theme: {
|
||||
background: '#0f172a', foreground: '#e2e8f0', cursor: '#3b82f6',
|
||||
black: '#1e293b', red: '#ef4444', green: '#22c55e', yellow: '#eab308',
|
||||
blue: '#3b82f6', magenta: '#a855f7', cyan: '#06b6d4', white: '#f1f5f9',
|
||||
brightBlack: '#475569', brightRed: '#f87171', brightGreen: '#4ade80',
|
||||
brightYellow: '#fbbf24', brightBlue: '#60a5fa', brightMagenta: '#c084fc',
|
||||
brightCyan: '#22d3ee', brightWhite: '#ffffff',
|
||||
},
|
||||
allowProposedApi: false,
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(termRef.current);
|
||||
fit.fit();
|
||||
|
||||
xtermRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
const disposable = term.onData((data) => {
|
||||
if (socketRef.current?.connected) socketRef.current.emit('data', data);
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit('resize', { cols: term.cols, rows: term.rows });
|
||||
}
|
||||
});
|
||||
if (termRef.current) ro.observe(termRef.current);
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
ro.disconnect();
|
||||
term.dispose();
|
||||
socketRef.current?.disconnect();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fit after maximize toggle
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
fitRef.current?.fit();
|
||||
if (socketRef.current?.connected && xtermRef.current) {
|
||||
socketRef.current.emit('resize', { cols: xtermRef.current.cols, rows: xtermRef.current.rows });
|
||||
}
|
||||
}, 50);
|
||||
}, [maximized]);
|
||||
|
||||
// ─── Maximize / restore ───────────────────────────────────────────────────
|
||||
const toggleMaximize = () => {
|
||||
if (!maximized) {
|
||||
savedGeometry.current = { pos: { ...pos }, size: { ...size } };
|
||||
setMaximized(true);
|
||||
} else {
|
||||
setMaximized(false);
|
||||
setPos(savedGeometry.current.pos);
|
||||
setSize(savedGeometry.current.size);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Drag (title bar) ─────────────────────────────────────────────────────
|
||||
const handleTitleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (maximized || e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
const el = windowRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origX = el.offsetLeft;
|
||||
const origY = el.offsetTop;
|
||||
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
el.style.left = `${Math.max(0, origX + me.clientX - startX)}px`;
|
||||
el.style.top = `${Math.max(0, origY + me.clientY - startY)}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
setPos({ x: el.offsetLeft, y: el.offsetTop });
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// ─── Resize (edge / corner handles) ───────────────────────────────────────
|
||||
const handleResizeMouseDown = (e: React.MouseEvent, dir: string) => {
|
||||
if (maximized || e.button !== 0) return;
|
||||
const el = windowRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origL = el.offsetLeft;
|
||||
const origT = el.offsetTop;
|
||||
const origW = el.offsetWidth;
|
||||
const origH = el.offsetHeight;
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const dx = me.clientX - startX;
|
||||
const dy = me.clientY - startY;
|
||||
let l = origL, t = origT, w = origW, h = origH;
|
||||
|
||||
if (dir.includes('e')) w = Math.max(MIN_W, origW + dx);
|
||||
if (dir.includes('s')) h = Math.max(MIN_H, origH + dy);
|
||||
if (dir.includes('w')) { w = Math.max(MIN_W, origW - dx); l = origL + origW - w; }
|
||||
if (dir.includes('n')) { h = Math.max(MIN_H, origH - dy); t = origT + origH - h; }
|
||||
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.body.style.userSelect = '';
|
||||
setPos ({ x: el.offsetLeft, y: el.offsetTop });
|
||||
setSize({ w: el.offsetWidth, h: el.offsetHeight });
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleReconnect = () => { xtermRef.current?.clear(); connect(); };
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[9999] bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Window */}
|
||||
<div
|
||||
ref={windowRef}
|
||||
className="flex flex-col bg-slate-900 shadow-2xl border border-slate-700 overflow-hidden"
|
||||
style={maximized
|
||||
? { position: 'fixed', inset: 0, zIndex: 10000, borderRadius: 0 }
|
||||
: { position: 'fixed', left: pos.x, top: pos.y, width: size.w, height: size.h,
|
||||
zIndex: 10000, borderRadius: '0.75rem', minWidth: MIN_W, minHeight: MIN_H }
|
||||
}
|
||||
>
|
||||
{/* Resize handles */}
|
||||
{!maximized && RESIZE_HANDLES.map(({ dir, cursor, style }) => (
|
||||
<div
|
||||
key={dir}
|
||||
style={{ position: 'absolute', zIndex: 10, cursor, ...style }}
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, dir)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Title bar — drag target */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-slate-800 border-b border-slate-700 select-none shrink-0"
|
||||
style={{ cursor: maximized ? 'default' : 'grab' }}
|
||||
onMouseDown={handleTitleMouseDown}
|
||||
onDoubleClick={toggleMaximize}
|
||||
>
|
||||
<TerminalSquare className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-200 flex-1 truncate">
|
||||
SSH — {deviceName}
|
||||
</span>
|
||||
|
||||
{connState === 'connecting' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-yellow-400">
|
||||
<RefreshCw className="w-3 h-3 animate-spin" /> Connecting…
|
||||
</span>
|
||||
)}
|
||||
{connState === 'ready' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" /> Connected
|
||||
</span>
|
||||
)}
|
||||
{(connState === 'error' || connState === 'closed') && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 px-2 py-0.5 rounded border border-blue-700 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Reconnect
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleMaximize}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
||||
title={maximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{maximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded text-slate-400 hover:text-red-400 hover:bg-slate-700 transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{connState === 'error' && errorMsg && (
|
||||
<div className="flex items-start gap-2 px-4 py-2.5 bg-red-900/40 border-b border-red-800 text-sm text-red-300">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="flex-1 min-h-0 p-1 bg-[#0f172a]">
|
||||
<div ref={termRef} className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
MapPin, Building2, Hash, FileText, Pencil, Check, X, RefreshCw, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { Device } from '../../types';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface Props {
|
||||
device: Device;
|
||||
}
|
||||
|
||||
interface LocationForm {
|
||||
location_address: string;
|
||||
rack_name: string;
|
||||
rack_slot: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
||||
}
|
||||
} catch {
|
||||
// ignore geocoding failures
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | string; address: string }) {
|
||||
const latN = Number(lat);
|
||||
const lngN = Number(lng);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const map = L.map(containerRef.current, { scrollWheelZoom: false });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
L.circleMarker([latN, lngN], {
|
||||
radius: 9, color: '#fff', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.9,
|
||||
}).bindPopup(`<b>${address}</b>`).addTo(map);
|
||||
map.setView([latN, lngN], 15);
|
||||
return () => { map.remove(); };
|
||||
}, [latN, lngN, address]);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<MapPin className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex-1 truncate">
|
||||
{address}
|
||||
</h3>
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${latN}&mlon=${lngN}#map=16/${latN}/${lngN}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-500 hover:text-blue-400 whitespace-nowrap"
|
||||
>
|
||||
Open map ↗
|
||||
</a>
|
||||
</div>
|
||||
<div ref={containerRef} style={{ height: 280, isolation: 'isolate' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceLocationSection({ device }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [form, setForm] = useState<LocationForm>({
|
||||
location_address: device.location_address ?? '',
|
||||
rack_name: device.rack_name ?? '',
|
||||
rack_slot: device.rack_slot ?? '',
|
||||
notes: device.notes ?? '',
|
||||
});
|
||||
|
||||
const hasMap = device.location_lat != null && device.location_lng != null
|
||||
&& !isNaN(Number(device.location_lat)) && !isNaN(Number(device.location_lng));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (f: LocationForm) => {
|
||||
setSaveError('');
|
||||
let lat: number | null = device.location_lat ?? null;
|
||||
let lng: number | null = device.location_lng ?? null;
|
||||
|
||||
// Re-geocode if address changed
|
||||
const addressChanged = f.location_address !== (device.location_address ?? '');
|
||||
if (f.location_address && addressChanged) {
|
||||
setGeocoding(true);
|
||||
const coords = await geocodeAddress(f.location_address);
|
||||
setGeocoding(false);
|
||||
if (coords) {
|
||||
lat = coords.lat;
|
||||
lng = coords.lng;
|
||||
} else {
|
||||
lat = null;
|
||||
lng = null;
|
||||
}
|
||||
}
|
||||
// Clear coords if address was cleared
|
||||
if (!f.location_address) { lat = null; lng = null; }
|
||||
|
||||
return devicesApi.patchLocation(device.id, {
|
||||
location_address: f.location_address || null,
|
||||
location_lat: lat,
|
||||
location_lng: lng,
|
||||
rack_name: f.rack_name || null,
|
||||
rack_slot: f.rack_slot || null,
|
||||
notes: f.notes || null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['device', device.id] });
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
setGeocoding(false);
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSaveError(msg || 'Failed to save');
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = () => {
|
||||
setForm({
|
||||
location_address: device.location_address ?? '',
|
||||
rack_name: device.rack_name ?? '',
|
||||
rack_slot: device.rack_slot ?? '',
|
||||
notes: device.notes ?? '',
|
||||
});
|
||||
setSaveError('');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => { setEditing(false); setSaveError(''); };
|
||||
|
||||
const isPending = mutation.isPending || geocoding;
|
||||
|
||||
return (
|
||||
<div className={`grid gap-4 ${hasMap && !editing ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Info / edit card */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Physical Details</h3>
|
||||
{canWrite && !editing && (
|
||||
<button onClick={openEdit} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-500" /> Physical Location
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.location_address}
|
||||
onChange={(e) => setForm((f) => ({ ...f, location_address: e.target.value }))}
|
||||
placeholder="123 Main St, City, State 00000"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Address will be geocoded and shown on the map.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-blue-500" /> Rack Name
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.rack_name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rack_name: e.target.value }))}
|
||||
placeholder="Rack-A1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<Hash className="w-3.5 h-3.5 text-blue-500" /> Rack Slot
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.rack_slot}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rack_slot: e.target.value }))}
|
||||
placeholder="U3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-blue-500" /> Notes
|
||||
</label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
rows={4}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
placeholder="Any relevant notes about this device..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => mutation.mutate(form)}
|
||||
disabled={isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{isPending
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" />{geocoding ? 'Geocoding…' : 'Saving…'}</>
|
||||
: <><Check className="w-3.5 h-3.5" />Save</>
|
||||
}
|
||||
</button>
|
||||
<button onClick={cancelEdit} disabled={isPending} className="btn-secondary text-sm">
|
||||
<X className="w-3.5 h-3.5 inline mr-1" />Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm">
|
||||
<InfoRow icon={<MapPin className="w-3.5 h-3.5 text-blue-500" />} label="Location"
|
||||
value={device.location_address}
|
||||
emptyText="No location set" />
|
||||
<InfoRow icon={<Building2 className="w-3.5 h-3.5 text-blue-500" />} label="Rack"
|
||||
value={device.rack_name}
|
||||
emptyText="No rack assigned" />
|
||||
<InfoRow icon={<Hash className="w-3.5 h-3.5 text-blue-500" />} label="Rack Slot"
|
||||
value={device.rack_slot}
|
||||
emptyText="—" />
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-slate-700">
|
||||
<div className="flex items-start gap-2 mt-2">
|
||||
<FileText className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide block mb-1">Notes</span>
|
||||
{device.notes
|
||||
? <p className="text-gray-700 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">{device.notes}</p>
|
||||
: <p className="text-gray-400 dark:text-slate-500 italic">No notes</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Map card — only when we have coordinates and not editing */}
|
||||
{hasMap && !editing && (
|
||||
<MapEmbed
|
||||
lat={device.location_lat!}
|
||||
lng={device.location_lng!}
|
||||
address={device.location_address!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
icon, label, value, emptyText,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide block mb-0.5">
|
||||
{label}
|
||||
</span>
|
||||
{value
|
||||
? <span className="text-gray-900 dark:text-white">{value}</span>
|
||||
: <span className="text-gray-400 dark:text-slate-500 italic">{emptyText}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Pencil, AlertCircle, RefreshCw, Check, X, ArrowRightLeft, Shield } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Shared types ─────────────────────────────────────────────────────────────
|
||||
interface FirewallRule extends Record<string, string> {
|
||||
'.id': string;
|
||||
chain: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
interface NatRule extends Record<string, string> {
|
||||
'.id': string;
|
||||
chain: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
// ─── Firewall constants ───────────────────────────────────────────────────────
|
||||
const COMMON_CHAINS = ['forward', 'input', 'output'];
|
||||
const FW_ACTIONS = ['accept', 'drop', 'reject', 'log', 'passthrough', 'jump', 'return'];
|
||||
const PROTOCOLS = ['', 'tcp', 'udp', 'icmp', 'ip', 'gre', 'ospf'];
|
||||
const CONN_STATES = ['new', 'established', 'related', 'invalid'];
|
||||
|
||||
const ACTION_COLOR: Record<string, string> = {
|
||||
accept: 'text-green-600 dark:text-green-400',
|
||||
drop: 'text-red-600 dark:text-red-400',
|
||||
reject: 'text-red-500 dark:text-red-400',
|
||||
log: 'text-yellow-600 dark:text-yellow-400',
|
||||
passthrough: 'text-blue-600 dark:text-blue-400',
|
||||
jump: 'text-purple-600 dark:text-purple-400',
|
||||
return: 'text-indigo-500 dark:text-indigo-400',
|
||||
masquerade: 'text-orange-600 dark:text-orange-400',
|
||||
'src-nat': 'text-blue-600 dark:text-blue-400',
|
||||
'dst-nat': 'text-cyan-600 dark:text-cyan-400',
|
||||
netmap: 'text-teal-600 dark:text-teal-400',
|
||||
redirect: 'text-violet-600 dark:text-violet-400',
|
||||
};
|
||||
|
||||
// ─── NAT constants ────────────────────────────────────────────────────────────
|
||||
const NAT_CHAINS = ['srcnat', 'dstnat'];
|
||||
const NAT_ACTIONS = ['masquerade', 'src-nat', 'dst-nat', 'netmap', 'redirect', 'accept', 'drop', 'return', 'jump', 'passthrough'];
|
||||
|
||||
// ─── Firewall rule form ───────────────────────────────────────────────────────
|
||||
interface RuleForm {
|
||||
chain: string; action: string;
|
||||
src_address: string; dst_address: string;
|
||||
protocol: string; src_port: string; dst_port: string;
|
||||
in_interface: string; out_interface: string;
|
||||
connection_state: string; jump_target: string;
|
||||
comment: string; disabled: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FW_FORM: RuleForm = {
|
||||
chain: 'forward', action: 'accept',
|
||||
src_address: '', dst_address: '', protocol: '',
|
||||
src_port: '', dst_port: '',
|
||||
in_interface: '', out_interface: '',
|
||||
connection_state: '', jump_target: '',
|
||||
comment: '', disabled: false,
|
||||
};
|
||||
|
||||
function ruleToForm(r: FirewallRule): RuleForm {
|
||||
const states = (r['connection-state'] ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
return {
|
||||
chain: r.chain ?? 'forward', action: r.action ?? 'accept',
|
||||
src_address: r['src-address'] ?? '', dst_address: r['dst-address'] ?? '',
|
||||
protocol: r.protocol ?? '',
|
||||
src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '',
|
||||
in_interface: r['in-interface'] ?? '', out_interface: r['out-interface'] ?? '',
|
||||
connection_state: states.join(','), jump_target: r['jump-target'] ?? '',
|
||||
comment: r.comment ?? '', disabled: r.disabled === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(f: RuleForm): Record<string, unknown> {
|
||||
const p: Record<string, unknown> = { chain: f.chain, action: f.action, disabled: f.disabled ? 'yes' : 'no' };
|
||||
if (f.src_address) p.src_address = f.src_address;
|
||||
if (f.dst_address) p.dst_address = f.dst_address;
|
||||
if (f.protocol) p.protocol = f.protocol;
|
||||
if (f.src_port) p.src_port = f.src_port;
|
||||
if (f.dst_port) p.dst_port = f.dst_port;
|
||||
if (f.in_interface) p.in_interface = f.in_interface;
|
||||
if (f.out_interface) p.out_interface = f.out_interface;
|
||||
if (f.connection_state) p.connection_state = f.connection_state;
|
||||
if (f.jump_target) p.jump_target = f.jump_target;
|
||||
if (f.comment) p.comment = f.comment;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ─── NAT rule form ────────────────────────────────────────────────────────────
|
||||
interface NatForm {
|
||||
chain: string; action: string;
|
||||
src_address: string; dst_address: string;
|
||||
protocol: string; src_port: string; dst_port: string;
|
||||
in_interface: string; out_interface: string;
|
||||
to_addresses: string; to_ports: string;
|
||||
comment: string; disabled: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_NAT_FORM: NatForm = {
|
||||
chain: 'srcnat', action: 'masquerade',
|
||||
src_address: '', dst_address: '', protocol: '',
|
||||
src_port: '', dst_port: '',
|
||||
in_interface: '', out_interface: '',
|
||||
to_addresses: '', to_ports: '',
|
||||
comment: '', disabled: false,
|
||||
};
|
||||
|
||||
function natRuleToForm(r: NatRule): NatForm {
|
||||
return {
|
||||
chain: r.chain ?? 'srcnat', action: r.action ?? 'masquerade',
|
||||
src_address: r['src-address'] ?? '', dst_address: r['dst-address'] ?? '',
|
||||
protocol: r.protocol ?? '',
|
||||
src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '',
|
||||
in_interface: r['in-interface'] ?? '', out_interface: r['out-interface'] ?? '',
|
||||
to_addresses: r['to-addresses'] ?? '', to_ports: r['to-ports'] ?? '',
|
||||
comment: r.comment ?? '', disabled: r.disabled === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
function natFormToPayload(f: NatForm): Record<string, unknown> {
|
||||
const p: Record<string, unknown> = { chain: f.chain, action: f.action, disabled: f.disabled ? 'yes' : 'no' };
|
||||
if (f.src_address) p.src_address = f.src_address;
|
||||
if (f.dst_address) p.dst_address = f.dst_address;
|
||||
if (f.protocol) p.protocol = f.protocol;
|
||||
if (f.src_port) p.src_port = f.src_port;
|
||||
if (f.dst_port) p.dst_port = f.dst_port;
|
||||
if (f.in_interface) p.in_interface = f.in_interface;
|
||||
if (f.out_interface)p.out_interface= f.out_interface;
|
||||
if (f.to_addresses) p.to_addresses = f.to_addresses;
|
||||
if (f.to_ports) p.to_ports = f.to_ports;
|
||||
if (f.comment) p.comment = f.comment;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function errMsg(err: unknown) {
|
||||
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed';
|
||||
}
|
||||
|
||||
function DisabledToggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-slate-300">Disabled</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!value)}
|
||||
className={clsx('relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
value ? 'bg-gray-300 dark:bg-slate-600' : 'bg-blue-600')}
|
||||
>
|
||||
<span className={clsx('inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
value ? 'translate-x-1' : 'translate-x-6')} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Firewall Rule Modal ──────────────────────────────────────────────────────
|
||||
function RuleModal({
|
||||
title, form, setForm, onSave, onClose, isPending, error,
|
||||
}: {
|
||||
title: string; form: RuleForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<RuleForm>>;
|
||||
onSave: () => void; onClose: () => void;
|
||||
isPending: boolean; error: string;
|
||||
}) {
|
||||
const set = (key: keyof RuleForm, val: string | boolean) => setForm(f => ({ ...f, [key]: val }));
|
||||
|
||||
const toggleConnState = (state: string) => {
|
||||
const current = form.connection_state ? form.connection_state.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const next = current.includes(state) ? current.filter(s => s !== state) : [...current, state];
|
||||
set('connection_state', next.join(','));
|
||||
};
|
||||
|
||||
const connStates = form.connection_state ? form.connection_state.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const hasPorts = form.protocol === 'tcp' || form.protocol === 'udp' || form.protocol === '';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Basic</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Chain</label>
|
||||
<input list="chains-list" className="input" value={form.chain} onChange={e => set('chain', e.target.value)} placeholder="forward" />
|
||||
<datalist id="chains-list">{COMMON_CHAINS.map(c => <option key={c} value={c} />)}</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Action</label>
|
||||
<select className="input" value={form.action} onChange={e => set('action', e.target.value)}>
|
||||
{FW_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{form.action === 'jump' && (
|
||||
<div>
|
||||
<label className="label">Jump Target Chain</label>
|
||||
<input className="input" value={form.jump_target} onChange={e => set('jump_target', e.target.value)} placeholder="custom-chain" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => set('comment', e.target.value)} placeholder="Optional description…" />
|
||||
</div>
|
||||
<DisabledToggle value={form.disabled} onChange={v => set('disabled', v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Address & Port</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Protocol</label>
|
||||
<select className="input" value={form.protocol} onChange={e => set('protocol', e.target.value)}>
|
||||
<option value="">— any —</option>
|
||||
{PROTOCOLS.filter(Boolean).map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Address</label>
|
||||
<input className="input font-mono" value={form.src_address} onChange={e => set('src_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
<div><label className="label">Dst Address</label>
|
||||
<input className="input font-mono" value={form.dst_address} onChange={e => set('dst_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
</div>
|
||||
{hasPorts && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Port</label>
|
||||
<input className="input font-mono" value={form.src_port} onChange={e => set('src_port', e.target.value)} placeholder="e.g. 80 or 1024-2048" /></div>
|
||||
<div><label className="label">Dst Port</label>
|
||||
<input className="input font-mono" value={form.dst_port} onChange={e => set('dst_port', e.target.value)} placeholder="e.g. 443" /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Interface</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">In Interface</label>
|
||||
<input className="input font-mono" value={form.in_interface} onChange={e => set('in_interface', e.target.value)} placeholder="ether1" /></div>
|
||||
<div><label className="label">Out Interface</label>
|
||||
<input className="input font-mono" value={form.out_interface} onChange={e => set('out_interface', e.target.value)} placeholder="ether2" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Connection State</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CONN_STATES.map(s => (
|
||||
<button key={s} type="button" onClick={() => toggleConnState(s)}
|
||||
className={clsx('px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors',
|
||||
connStates.includes(s)
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:border-blue-400'
|
||||
)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||||
<button onClick={onSave} disabled={isPending} className="btn-primary flex items-center gap-2">
|
||||
{isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} Save Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── NAT Rule Modal ───────────────────────────────────────────────────────────
|
||||
function NatModal({
|
||||
title, form, setForm, onSave, onClose, isPending, error,
|
||||
}: {
|
||||
title: string; form: NatForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<NatForm>>;
|
||||
onSave: () => void; onClose: () => void;
|
||||
isPending: boolean; error: string;
|
||||
}) {
|
||||
const set = (key: keyof NatForm, val: string | boolean) => setForm(f => ({ ...f, [key]: val }));
|
||||
|
||||
const needsTarget = ['src-nat', 'dst-nat', 'netmap'].includes(form.action);
|
||||
const needsPort = ['redirect', 'dst-nat', 'src-nat'].includes(form.action);
|
||||
const hasPorts = form.protocol === 'tcp' || form.protocol === 'udp' || form.protocol === '';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Basic */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Basic</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Chain</label>
|
||||
<select className="input" value={form.chain} onChange={e => set('chain', e.target.value)}>
|
||||
{NAT_CHAINS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Action</label>
|
||||
<select className="input" value={form.action} onChange={e => set('action', e.target.value)}>
|
||||
{NAT_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* action description */}
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{form.action === 'masquerade' && 'Replaces source IP with router\'s outgoing interface IP. Best for dynamic WAN IPs.'}
|
||||
{form.action === 'src-nat' && 'Replaces source IP with a static address (specify in To Addresses).'}
|
||||
{form.action === 'dst-nat' && 'Replaces destination IP/port, used for port forwarding.'}
|
||||
{form.action === 'netmap' && 'One-to-one address translation. Requires To Addresses.'}
|
||||
{form.action === 'redirect' && 'Redirects packets to the router itself (specify To Ports for target port).'}
|
||||
</p>
|
||||
<div>
|
||||
<label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => set('comment', e.target.value)} placeholder="Optional description…" />
|
||||
</div>
|
||||
<DisabledToggle value={form.disabled} onChange={v => set('disabled', v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Match Criteria</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Protocol</label>
|
||||
<select className="input" value={form.protocol} onChange={e => set('protocol', e.target.value)}>
|
||||
<option value="">— any —</option>
|
||||
{PROTOCOLS.filter(Boolean).map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Address</label>
|
||||
<input className="input font-mono" value={form.src_address} onChange={e => set('src_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
<div><label className="label">Dst Address</label>
|
||||
<input className="input font-mono" value={form.dst_address} onChange={e => set('dst_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
</div>
|
||||
{hasPorts && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Port</label>
|
||||
<input className="input font-mono" value={form.src_port} onChange={e => set('src_port', e.target.value)} placeholder="e.g. 80" /></div>
|
||||
<div><label className="label">Dst Port</label>
|
||||
<input className="input font-mono" value={form.dst_port} onChange={e => set('dst_port', e.target.value)} placeholder="e.g. 443" /></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">In Interface</label>
|
||||
<input className="input font-mono" value={form.in_interface} onChange={e => set('in_interface', e.target.value)} placeholder="ether1" /></div>
|
||||
<div><label className="label">Out Interface</label>
|
||||
<input className="input font-mono" value={form.out_interface} onChange={e => set('out_interface', e.target.value)} placeholder="ether2" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation target */}
|
||||
{(needsTarget || needsPort) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Translation Target</h4>
|
||||
<div className="space-y-3">
|
||||
{needsTarget && (
|
||||
<div><label className="label">To Addresses</label>
|
||||
<input className="input font-mono" value={form.to_addresses} onChange={e => set('to_addresses', e.target.value)} placeholder="e.g. 192.168.1.10 or 192.168.1.10-192.168.1.20" /></div>
|
||||
)}
|
||||
{needsPort && (
|
||||
<div><label className="label">To Ports</label>
|
||||
<input className="input font-mono" value={form.to_ports} onChange={e => set('to_ports', e.target.value)} placeholder="e.g. 8080 or 8080-8090" /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||||
<button onClick={onSave} disabled={isPending} className="btn-primary flex items-center gap-2">
|
||||
{isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} Save Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── NAT Card (router-only) ───────────────────────────────────────────────────
|
||||
function NatCard({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const [chainFilter, setChainFilter] = useState('all');
|
||||
const [editingRule, setEditingRule] = useState<NatRule | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState<NatForm>(EMPTY_NAT_FORM);
|
||||
const [mutError, setMutError] = useState('');
|
||||
|
||||
const { data: rules = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['nat', deviceId],
|
||||
queryFn: () => devicesApi.getNat(deviceId).then(r => r.data as NatRule[]),
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['nat', deviceId] });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => devicesApi.addNatRule(deviceId, data),
|
||||
onSuccess: () => { invalidate(); setShowAdd(false); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
devicesApi.updateNatRule(deviceId, id, data),
|
||||
onSuccess: () => { invalidate(); setEditingRule(null); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => devicesApi.deleteNatRule(deviceId, id),
|
||||
onSuccess: () => { invalidate(); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) =>
|
||||
devicesApi.updateNatRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
|
||||
const openAdd = () => {
|
||||
setForm({ ...EMPTY_NAT_FORM, chain: chainFilter !== 'all' ? chainFilter : 'srcnat' });
|
||||
setMutError('');
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const openEdit = (rule: NatRule) => {
|
||||
setForm(natRuleToForm(rule));
|
||||
setMutError('');
|
||||
setEditingRule(rule);
|
||||
};
|
||||
|
||||
const allChains = ['all', ...Array.from(new Set(rules.map(r => r.chain).filter(Boolean))).sort()];
|
||||
const filtered = chainFilter === 'all' ? rules : rules.filter(r => r.chain === chainFilter);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<div className="w-7 h-7 bg-orange-50 dark:bg-orange-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<ArrowRightLeft className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Network Address Translation (NAT)</h3>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-slate-800 p-1 rounded-lg">
|
||||
{allChains.map(c => (
|
||||
<button key={c} onClick={() => setChainFilter(c)}
|
||||
className={clsx('px-3 py-1 text-xs font-medium rounded-md transition-colors capitalize',
|
||||
chainFilter === c
|
||||
? 'bg-white dark:bg-slate-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200'
|
||||
)}>
|
||||
{c === 'all' ? `All (${rules.length})` : `${c} (${rules.filter(r => r.chain === c).length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={openAdd} className="btn-primary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> Add Rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{mutError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-6 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading NAT rules…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card p-8 text-center text-gray-400 dark:text-slate-500">
|
||||
No NAT rules{chainFilter !== 'all' ? ` in chain "${chainFilter}"` : ''}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-3 py-2.5 text-left w-8">#</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Chain</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Action</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Proto</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Src</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Dst</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Ports</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">To</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-3 py-2.5 w-24" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{filtered.map((r, i) => {
|
||||
const disabled = r.disabled === 'true';
|
||||
const srcPort = r['src-port'];
|
||||
const dstPort = r['dst-port'];
|
||||
const portCell = [srcPort && `src:${srcPort}`, dstPort && `dst:${dstPort}`].filter(Boolean).join(' ');
|
||||
const toCell = [
|
||||
r['to-addresses'] && r['to-addresses'],
|
||||
r['to-ports'] && `:${r['to-ports']}`,
|
||||
].filter(Boolean).join('');
|
||||
return (
|
||||
<tr key={r['.id'] ?? i} className={clsx('hover:bg-gray-50 dark:hover:bg-slate-700/30', disabled && 'opacity-40')}>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700 dark:text-slate-300">{r.chain}</td>
|
||||
<td className={clsx('px-3 py-2 text-xs font-bold uppercase', ACTION_COLOR[r.action] ?? 'text-gray-500')}>
|
||||
{r.action}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{r.protocol || 'any'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['src-address'], r['in-interface'] && `in:${r['in-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['dst-address'], r['out-interface'] && `out:${r['out-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{portCell || '—'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-orange-600 dark:text-orange-400">{toCell || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic max-w-[120px] truncate">{r.comment || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
title={disabled ? 'Enable' : 'Disable'}
|
||||
onClick={() => toggleMutation.mutate({ id: r['.id'], disabled: !disabled })}
|
||||
className={clsx('p-1 rounded transition-colors', disabled ? 'text-gray-400 hover:text-green-500' : 'text-green-500 hover:text-gray-400')}
|
||||
>
|
||||
<span className={clsx('inline-block w-2 h-2 rounded-full', disabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
</button>
|
||||
<button onClick={() => openEdit(r)} className="p-1 rounded text-gray-400 hover:text-blue-500 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this NAT rule?')) deleteMutation.mutate(r['.id']); }}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<NatModal
|
||||
title="Add NAT Rule"
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => addMutation.mutate(natFormToPayload(form))}
|
||||
onClose={() => setShowAdd(false)}
|
||||
isPending={addMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
{editingRule && (
|
||||
<NatModal
|
||||
title={`Edit NAT Rule #${rules.indexOf(editingRule) + 1}`}
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => updateMutation.mutate({ id: editingRule['.id'], data: natFormToPayload(form) })}
|
||||
onClose={() => setEditingRule(null)}
|
||||
isPending={updateMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main FirewallTab ─────────────────────────────────────────────────────────
|
||||
export default function FirewallTab({ deviceId, deviceType }: { deviceId: number; deviceType?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const [chainFilter, setChainFilter] = useState('all');
|
||||
const [editingRule, setEditingRule] = useState<FirewallRule | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState<RuleForm>(EMPTY_FW_FORM);
|
||||
const [mutError, setMutError] = useState('');
|
||||
|
||||
const { data: rules = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['firewall', deviceId],
|
||||
queryFn: () => devicesApi.getFirewall(deviceId).then(r => r.data as FirewallRule[]),
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['firewall', deviceId] });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => devicesApi.addFirewallRule(deviceId, data),
|
||||
onSuccess: () => { invalidate(); setShowAdd(false); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
devicesApi.updateFirewallRule(deviceId, id, data),
|
||||
onSuccess: () => { invalidate(); setEditingRule(null); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => devicesApi.deleteFirewallRule(deviceId, id),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) =>
|
||||
devicesApi.updateFirewallRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
|
||||
const openAdd = () => {
|
||||
setForm({ ...EMPTY_FW_FORM, chain: chainFilter !== 'all' ? chainFilter : 'forward' });
|
||||
setMutError('');
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const openEdit = (rule: FirewallRule) => {
|
||||
setForm(ruleToForm(rule));
|
||||
setMutError('');
|
||||
setEditingRule(rule);
|
||||
};
|
||||
|
||||
const allChains = ['all', ...Array.from(new Set(rules.map(r => r.chain).filter(Boolean))).sort()];
|
||||
const filtered = chainFilter === 'all' ? rules : rules.filter(r => r.chain === chainFilter);
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section heading */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Shield className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Firewall Rules</h3>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-slate-800 p-1 rounded-lg flex-wrap">
|
||||
{allChains.map(c => (
|
||||
<button key={c} onClick={() => setChainFilter(c)}
|
||||
className={clsx('px-3 py-1 text-xs font-medium rounded-md transition-colors capitalize',
|
||||
chainFilter === c
|
||||
? 'bg-white dark:bg-slate-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200'
|
||||
)}>
|
||||
{c === 'all' ? `All (${rules.length})` : `${c} (${rules.filter(r => r.chain === c).length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={openAdd} className="btn-primary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> Add Rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{mutError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="card p-8 text-center text-gray-400 dark:text-slate-500">
|
||||
No firewall rules{chainFilter !== 'all' ? ` in chain "${chainFilter}"` : ''}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-3 py-2.5 text-left w-8">#</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Chain</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Action</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Protocol</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Src</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Dst</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Ports</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">State</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-3 py-2.5 w-24" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{filtered.map((r, i) => {
|
||||
const disabled = r.disabled === 'true';
|
||||
const srcPort = r['src-port'];
|
||||
const dstPort = r['dst-port'];
|
||||
const portCell = [srcPort && `src:${srcPort}`, dstPort && `dst:${dstPort}`].filter(Boolean).join(' ');
|
||||
return (
|
||||
<tr key={r['.id'] ?? i} className={clsx('hover:bg-gray-50 dark:hover:bg-slate-700/30', disabled && 'opacity-40')}>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700 dark:text-slate-300">{r.chain}</td>
|
||||
<td className={clsx('px-3 py-2 text-xs font-bold uppercase', ACTION_COLOR[r.action] ?? 'text-gray-500')}>
|
||||
{r.action}{r['jump-target'] ? ` → ${r['jump-target']}` : ''}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{r.protocol || 'any'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['src-address'], r['in-interface'] && `in:${r['in-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['dst-address'], r['out-interface'] && `out:${r['out-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{portCell || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{r['connection-state'] || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic max-w-[140px] truncate">{r.comment || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
title={disabled ? 'Enable' : 'Disable'}
|
||||
onClick={() => toggleMutation.mutate({ id: r['.id'], disabled: !disabled })}
|
||||
className={clsx('p-1 rounded transition-colors', disabled ? 'text-gray-400 hover:text-green-500' : 'text-green-500 hover:text-gray-400')}
|
||||
>
|
||||
<span className={clsx('inline-block w-2 h-2 rounded-full', disabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
</button>
|
||||
<button onClick={() => openEdit(r)} className="p-1 rounded text-gray-400 hover:text-blue-500 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this firewall rule?')) deleteMutation.mutate(r['.id']); }}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<RuleModal
|
||||
title="Add Firewall Rule"
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => addMutation.mutate(formToPayload(form))}
|
||||
onClose={() => setShowAdd(false)}
|
||||
isPending={addMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
{editingRule && (
|
||||
<RuleModal
|
||||
title={`Edit Rule #${rules.indexOf(editingRule) + 1}`}
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => updateMutation.mutate({ id: editingRule['.id'], data: formToPayload(form) })}
|
||||
onClose={() => setEditingRule(null)}
|
||||
isPending={updateMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* NAT card — routers only */}
|
||||
{deviceType === 'router' && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 dark:border-slate-700 pt-2" />
|
||||
<NatCard deviceId={deviceId} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { metricsApi, devicesApi } from '../../services/api';
|
||||
import type { Interface, TrafficPoint, ResourcePoint } from '../../types';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
}
|
||||
|
||||
function formatBps(val: number): string {
|
||||
if (val >= 1e9) return `${(val / 1e9).toFixed(2)} GB/s`;
|
||||
if (val >= 1e6) return `${(val / 1e6).toFixed(2)} MB/s`;
|
||||
if (val >= 1e3) return `${(val / 1e3).toFixed(1)} KB/s`;
|
||||
return `${Math.round(val)} B/s`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string, compact: boolean): string {
|
||||
const d = new Date(iso);
|
||||
if (compact) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleString([], {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const TRAFFIC_RANGES = ['1h', '3h', '6h', '12h', '24h'] as const;
|
||||
const RESOURCE_RANGES = ['6h', '12h', '24h', '7d'] as const;
|
||||
|
||||
export default function GraphsTab({ deviceId }: Props) {
|
||||
const [trafficIface, setTrafficIface] = useState('');
|
||||
const [trafficRange, setTrafficRange] = useState<string>('1h');
|
||||
const [resourceRange, setResourceRange] = useState<string>('24h');
|
||||
|
||||
const { data: interfaces = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data as Interface[]),
|
||||
});
|
||||
|
||||
const { data: rawTraffic = [] } = useQuery({
|
||||
queryKey: ['traffic', deviceId, trafficIface, trafficRange],
|
||||
queryFn: () =>
|
||||
metricsApi.interfaceTraffic(deviceId, trafficIface, trafficRange).then((r) => r.data),
|
||||
enabled: !!trafficIface,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: rawResources = [] } = useQuery({
|
||||
queryKey: ['resources-history', deviceId, resourceRange],
|
||||
queryFn: () =>
|
||||
metricsApi.deviceResources(deviceId, resourceRange).then((r) => r.data as ResourcePoint[]),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const compact = trafficRange === '1h' || trafficRange === '3h';
|
||||
const resourceCompact = resourceRange === '6h' || resourceRange === '12h' || resourceRange === '24h';
|
||||
|
||||
const trafficData = (rawTraffic as TrafficPoint[]).map((p) => ({
|
||||
time: formatTime(p.time, compact),
|
||||
rx: Math.round(p.rx),
|
||||
tx: Math.round(p.tx),
|
||||
}));
|
||||
|
||||
const resourceData = (rawResources as ResourcePoint[]).map((p) => {
|
||||
const memPct =
|
||||
p.memory_total && p.memory_total > 0
|
||||
? Math.round(((p.memory_used || 0) / p.memory_total) * 100)
|
||||
: 0;
|
||||
return {
|
||||
time: formatTime(p.time, resourceCompact),
|
||||
cpu: Math.round(p.cpu_load || 0),
|
||||
mem: memPct,
|
||||
};
|
||||
});
|
||||
|
||||
const RangeButtons = ({
|
||||
ranges,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
ranges: readonly string[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) => (
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onChange(r)}
|
||||
className={`px-2 py-1 text-xs rounded font-medium transition-colors ${
|
||||
value === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Interface Traffic ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Interface Traffic</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="input py-1.5 text-sm"
|
||||
value={trafficIface}
|
||||
onChange={(e) => setTrafficIface(e.target.value)}
|
||||
>
|
||||
<option value="">Select interface...</option>
|
||||
{(interfaces as Interface[])
|
||||
.filter((i) => !i.disabled)
|
||||
.map((i) => (
|
||||
<option key={i.name} value={i.name}>
|
||||
{i.name}
|
||||
{i.speed ? ` (${i.speed})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<RangeButtons ranges={TRAFFIC_RANGES} value={trafficRange} onChange={setTrafficRange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!trafficIface ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
Select an interface to view traffic history
|
||||
</div>
|
||||
) : trafficData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No traffic data yet for this interface in the selected range
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={trafficData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-gray-200 dark:text-slate-600" opacity={0.5} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tickFormatter={formatBps} tick={{ fontSize: 10 }} width={72} />
|
||||
<Tooltip formatter={(v) => formatBps(v as number)} contentStyle={{ fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="rx" stroke="#3b82f6" name="RX (download)" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="tx" stroke="#10b981" name="TX (upload)" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── CPU & Memory ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">CPU & Memory Usage</h3>
|
||||
<RangeButtons ranges={RESOURCE_RANGES} value={resourceRange} onChange={setResourceRange} />
|
||||
</div>
|
||||
|
||||
{resourceData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No resource history available yet — data accumulates over time
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={resourceData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-gray-200 dark:text-slate-600" opacity={0.5} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v) => `${v}%`} contentStyle={{ fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="cpu" stroke="#f59e0b" name="CPU Load" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="mem" stroke="#8b5cf6" name="Memory Used" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Thermometer, Zap, Wind, Power, Activity, HelpCircle, RefreshCw, HardDrive } from 'lucide-react';
|
||||
import { devicesApi, metricsApi } from '../../services/api';
|
||||
import type { ResourcePoint } from '../../types';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface HealthMetric {
|
||||
name: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// RouterOS health comes in two formats depending on version:
|
||||
// New (v7+): [{name:'temperature', value:'42', type:'C'}, ...]
|
||||
// Old (v6): [{'cpu-temperature':'42', voltage:'23.9', ...}]
|
||||
function normalize(raw: Record<string, string>[]): HealthMetric[] {
|
||||
if (!raw.length) return [];
|
||||
|
||||
if ('name' in raw[0]) {
|
||||
return raw.map((r) => ({
|
||||
name: r['name'] || '',
|
||||
value: r['value'] || '',
|
||||
unit: r['type'] || '',
|
||||
}));
|
||||
}
|
||||
|
||||
const obj = raw[0];
|
||||
return Object.entries(obj)
|
||||
.filter(([k]) => !k.startsWith('.'))
|
||||
.map(([name, value]) => ({ name, value, unit: inferUnit(name, value) }));
|
||||
}
|
||||
|
||||
function inferUnit(name: string, value: string): string {
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('temperature')) return 'C';
|
||||
if (n.includes('voltage')) return 'V';
|
||||
if (n.includes('current')) return 'A';
|
||||
if (n.includes('power') && /^\d/.test(value)) return 'W';
|
||||
if (n.includes('fan') && /^\d+$/.test(value)) return 'RPM';
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatName(name: string): string {
|
||||
return name
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.replace(/\bPsu\b/g, 'PSU')
|
||||
.replace(/\bNtp\b/g, 'NTP');
|
||||
}
|
||||
|
||||
// ─── Disk helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Parse sizes in either raw-bytes ("134217728") or human-readable ("7.5GiB", "128MiB") form
|
||||
function parseBytes(val: string): number {
|
||||
if (!val) return 0;
|
||||
const n = parseFloat(val);
|
||||
if (isNaN(n)) return 0;
|
||||
const unit = val.replace(/[\d.\s]/g, '').toUpperCase();
|
||||
const map: Record<string, number> = {
|
||||
'': 1, B: 1,
|
||||
KB: 1e3, KIB: 1024,
|
||||
MB: 1e6, MIB: 1024 ** 2,
|
||||
GB: 1e9, GIB: 1024 ** 3,
|
||||
TB: 1e12, TIB: 1024 ** 4,
|
||||
};
|
||||
return n * (map[unit] ?? 1);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KiB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function diskUsageColor(pct: number): string {
|
||||
if (pct >= 85) return 'bg-red-500';
|
||||
if (pct >= 70) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
// ─── Category helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function isTemperature(m: HealthMetric) {
|
||||
return m.unit === 'C' || m.name.toLowerCase().includes('temperature');
|
||||
}
|
||||
function isVoltage(m: HealthMetric) {
|
||||
return m.unit === 'V' || m.name.toLowerCase().includes('voltage');
|
||||
}
|
||||
function isFan(m: HealthMetric) {
|
||||
return m.unit === 'RPM' || m.name.toLowerCase().includes('fan');
|
||||
}
|
||||
function isPsu(m: HealthMetric) {
|
||||
const n = m.name.toLowerCase();
|
||||
return n.includes('psu') || n.includes('power-supply');
|
||||
}
|
||||
function isPower(m: HealthMetric) {
|
||||
return m.unit === 'W' || (m.name.toLowerCase().includes('power') && !isPsu(m));
|
||||
}
|
||||
function isCurrent(m: HealthMetric) {
|
||||
return m.unit === 'A' || m.name.toLowerCase().includes('current');
|
||||
}
|
||||
|
||||
// ─── Color helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function tempColor(celsius: number): string {
|
||||
if (celsius >= 70) return 'text-red-500 dark:text-red-400';
|
||||
if (celsius >= 50) return 'text-yellow-500 dark:text-yellow-400';
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
}
|
||||
|
||||
function fanColor(rpm: number): string {
|
||||
return rpm === 0 ? 'text-red-500 dark:text-red-400' : 'text-green-500 dark:text-green-400';
|
||||
}
|
||||
|
||||
function psuColor(state: string): string {
|
||||
const s = state.toLowerCase();
|
||||
if (s === 'ok' || s === 'good') return 'text-green-500 dark:text-green-400';
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
// ─── Card components ──────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, icon, children, grid = true }: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
grid?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-blue-500">{icon}</span>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
</div>
|
||||
{grid ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">{children}</div>
|
||||
) : (
|
||||
<div className="space-y-3">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, unit, valueClass }: {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
valueClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mb-1">{label}</div>
|
||||
<div className={clsx('text-lg font-bold', valueClass || 'text-gray-900 dark:text-white')}>
|
||||
{value}
|
||||
{unit && <span className="text-xs font-normal text-gray-500 dark:text-slate-400 ml-1">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiskCard({ disk }: { disk: Record<string, string> }) {
|
||||
const totalBytes = parseBytes(disk['total'] || '0');
|
||||
const freeBytes = parseBytes(disk['free'] || '0');
|
||||
const usedBytes = Math.max(0, totalBytes - freeBytes);
|
||||
const pct = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0;
|
||||
|
||||
const label = disk['label'] || formatName(disk['name'] || 'Disk');
|
||||
const type = disk['type'] || '';
|
||||
const model = disk['model'] || '';
|
||||
const serial = disk['serial'] || '';
|
||||
const fs = disk['filesystem'] || disk['fs'] || '';
|
||||
const slot = disk['slot'] || '';
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-white">{label}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 space-x-2">
|
||||
{type && <span className="uppercase">{type}</span>}
|
||||
{slot && <span>· {slot}</span>}
|
||||
{fs && <span>· {fs}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white">{pct}%</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400">used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full mb-2">
|
||||
<div
|
||||
className={clsx('h-full rounded-full transition-all', diskUsageColor(pct))}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-slate-400">
|
||||
<span>{formatBytes(usedBytes)} used</span>
|
||||
<span>{formatBytes(freeBytes)} free / {formatBytes(totalBytes)}</span>
|
||||
</div>
|
||||
|
||||
{(model || serial) && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-slate-600 text-xs text-gray-400 dark:text-slate-500 space-y-0.5">
|
||||
{model && <div>Model: {model}</div>}
|
||||
{serial && <div>S/N: {serial}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toDisplayTemp(celsius: number, unit: 'C' | 'F'): string {
|
||||
if (unit === 'F') return String(Math.round(celsius * 9 / 5 + 32));
|
||||
return String(celsius % 1 === 0 ? celsius : celsius.toFixed(1));
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
const RESOURCE_RANGES = ['6h', '12h', '24h', '7d'] as const;
|
||||
|
||||
function ResourceChart({ deviceId }: { deviceId: number }) {
|
||||
const [range, setRange] = useState<string>('24h');
|
||||
|
||||
const { data: rawResources = [] } = useQuery({
|
||||
queryKey: ['resources-history', deviceId, range],
|
||||
queryFn: () => metricsApi.deviceResources(deviceId, range).then((r) => r.data as ResourcePoint[]),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const compact = range === '6h' || range === '12h' || range === '24h';
|
||||
const resourceData = rawResources.map((p) => {
|
||||
const memPct =
|
||||
p.memory_total && p.memory_total > 0
|
||||
? Math.round(((p.memory_used || 0) / p.memory_total) * 100)
|
||||
: 0;
|
||||
return {
|
||||
time: new Date(p.time).toLocaleTimeString([], {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
...(compact ? {} : { month: 'short', day: 'numeric' }),
|
||||
}),
|
||||
cpu: Math.round(p.cpu_load || 0),
|
||||
mem: memPct,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">CPU & Memory Usage</h3>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{RESOURCE_RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No resource history available yet — data accumulates over time
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={resourceData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
formatter={(v) => `${v}%`}
|
||||
contentStyle={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="cpu" stroke="#f59e0b" name="CPU Load" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="mem" stroke="#8b5cf6" name="Memory Used" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwareTab({ deviceId }: { deviceId: number }) {
|
||||
const [tempUnit, setTempUnit] = useState<'C' | 'F'>(() =>
|
||||
(localStorage.getItem('hw-temp-unit') as 'C' | 'F') || 'C'
|
||||
);
|
||||
|
||||
const setAndPersistUnit = (unit: 'C' | 'F') => {
|
||||
setTempUnit(unit);
|
||||
localStorage.setItem('hw-temp-unit', unit);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
queryKey: ['device-hardware', deviceId],
|
||||
queryFn: () => devicesApi.getHardware(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card p-8 text-center text-gray-500 dark:text-slate-400">
|
||||
Failed to load hardware data.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = normalize(data?.health || []);
|
||||
const disks = data?.disks || [];
|
||||
|
||||
const temperatures = metrics.filter(isTemperature);
|
||||
const voltages = metrics.filter(isVoltage);
|
||||
const fans = metrics.filter(isFan);
|
||||
const psus = metrics.filter(isPsu);
|
||||
const power = metrics.filter(isPower);
|
||||
const current = metrics.filter(isCurrent);
|
||||
const other = metrics.filter(
|
||||
(m) => !isTemperature(m) && !isVoltage(m) && !isFan(m) && !isPsu(m) && !isPower(m) && !isCurrent(m)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{/* Temperature unit toggle */}
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-slate-600 text-xs font-medium">
|
||||
{(['C', 'F'] as const).map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
onClick={() => setAndPersistUnit(unit)}
|
||||
className={clsx(
|
||||
'px-2.5 py-1 transition-colors',
|
||||
tempUnit === unit
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
°{unit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CPU & Memory chart — full width at top */}
|
||||
<ResourceChart deviceId={deviceId} />
|
||||
|
||||
{/* Sensor & disk sections in a 2-column grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{disks.length > 0 && (
|
||||
<div className="lg:col-span-2">
|
||||
<Section title="Storage" icon={<HardDrive className="w-4 h-4" />} grid={false}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{disks.map((d, i) => <DiskCard key={d['name'] || i} disk={d} />)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{temperatures.length > 0 && (
|
||||
<Section title="Temperature" icon={<Thermometer className="w-4 h-4" />}>
|
||||
{temperatures.map((m) => {
|
||||
const c = parseFloat(m.value);
|
||||
return (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={isNaN(c) ? m.value : toDisplayTemp(c, tempUnit)}
|
||||
unit={`°${tempUnit}`}
|
||||
valueClass={isNaN(c) ? undefined : tempColor(c)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{fans.length > 0 && (
|
||||
<Section title="Fans" icon={<Wind className="w-4 h-4" />}>
|
||||
{fans.map((m) => {
|
||||
const rpm = parseInt(m.value, 10);
|
||||
return (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={isNaN(rpm) ? m.value : rpm.toLocaleString()}
|
||||
unit={isNaN(rpm) ? undefined : 'RPM'}
|
||||
valueClass={isNaN(rpm) ? undefined : fanColor(rpm)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{psus.length > 0 && (
|
||||
<Section title="Power Supply" icon={<Power className="w-4 h-4" />}>
|
||||
{psus.map((m) => (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={m.value.toUpperCase()}
|
||||
valueClass={psuColor(m.value)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{voltages.length > 0 && (
|
||||
<Section title="Voltage" icon={<Zap className="w-4 h-4" />}>
|
||||
{voltages.map((m) => (
|
||||
<MetricCard key={m.name} label={formatName(m.name)} value={m.value} unit="V" />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(power.length > 0 || current.length > 0) && (
|
||||
<Section title="Power Consumption" icon={<Activity className="w-4 h-4" />}>
|
||||
{power.map((m) => (
|
||||
<MetricCard key={m.name} label={formatName(m.name)} value={m.value} unit="W" />
|
||||
))}
|
||||
{current.map((m) => (
|
||||
<MetricCard key={m.name} label={formatName(m.name)} value={m.value} unit="A" />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{other.length > 0 && (
|
||||
<Section title="Other" icon={<HelpCircle className="w-4 h-4" />}>
|
||||
{other.map((m) => (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={m.value}
|
||||
unit={m.unit || undefined}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function InterfacesTab({ deviceId }: { deviceId: number }) {
|
||||
const { data: interfaces = [], isLoading } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
if (!interfaces.length) return (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-slate-500">
|
||||
No interfaces found. Sync the device to collect data.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Type</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MAC Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MTU</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Speed</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{interfaces.map((iface) => (
|
||||
<tr key={iface.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-gray-900 dark:text-white">
|
||||
{iface.name}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400 capitalize">
|
||||
{iface.type || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 text-xs font-medium',
|
||||
iface.disabled
|
||||
? 'text-gray-400'
|
||||
: iface.running
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
iface.disabled ? 'bg-gray-400' : iface.running ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
/>
|
||||
{iface.disabled ? 'Disabled' : iface.running ? 'Running' : 'Down'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{iface.mac_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{iface.mtu || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{iface.speed || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 italic">
|
||||
{iface.comment || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,716 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
// ─── Extended devicesApi type (routing protocol methods added at runtime) ──────
|
||||
const dApi = devicesApi as typeof devicesApi & {
|
||||
getOspf: (id: number) => Promise<{ data: Record<string, unknown> }>;
|
||||
getBgp: (id: number) => Promise<{ data: Record<string, unknown> }>;
|
||||
getRoutingTables: (id: number) => Promise<{ data: Record<string, string>[] }>;
|
||||
getRouteFilters: (id: number) => Promise<{ data: Record<string, unknown> }>;
|
||||
getRouterIds: (id: number) => Promise<{ data: Record<string, string>[] }>;
|
||||
addOspfInstance: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeOspfInstance: (id: number, itemId: string) => Promise<unknown>;
|
||||
addOspfArea: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeOspfArea: (id: number, itemId: string) => Promise<unknown>;
|
||||
addBgpConnection: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeBgpConnection: (id: number, itemId: string) => Promise<unknown>;
|
||||
addRoutingTable: (id: number, d: { name: string; fib?: boolean }) => Promise<unknown>;
|
||||
removeRoutingTable: (id: number, itemId: string) => Promise<unknown>;
|
||||
addFilterRule: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeFilterRule: (id: number, itemId: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const SUB_TABS = ['Routes', 'OSPF', 'BGP', 'Route Filters', 'Tables'] as const;
|
||||
type SubTab = (typeof SUB_TABS)[number];
|
||||
|
||||
// ─── Reusable helpers ─────────────────────────────────────────────────────────
|
||||
function errMsg(err: unknown) {
|
||||
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed';
|
||||
}
|
||||
|
||||
function KVTable({ rows }: { rows: Record<string, string>[] }) {
|
||||
if (!rows.length) return <Empty text="No entries." />;
|
||||
const keys = Object.keys(rows[0]).filter(k => k !== '.id');
|
||||
return (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
{keys.map(k => (
|
||||
<th key={k} className="table-header px-4 py-2.5 text-left">{k}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
{keys.map(k => (
|
||||
<td key={k} className="px-4 py-2.5 font-mono text-xs text-gray-700 dark:text-slate-300">
|
||||
{String(r[k] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ text }: { text: string }) {
|
||||
return <p className="text-center py-8 text-gray-400 dark:text-slate-500">{text}</p>;
|
||||
}
|
||||
|
||||
function SectionHeader({ title, onRefresh, isFetching }: { title: string; onRefresh: () => void; isFetching: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onRefresh} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Routes sub-tab (original) ────────────────────────────────────────────────
|
||||
function RoutesSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: routes = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing', deviceId],
|
||||
queryFn: () => devicesApi.getRouting(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
||||
const [addError, setAddError] = useState('');
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => devicesApi.addRoute(deviceId, {
|
||||
dst_address: form.dst_address,
|
||||
gateway: form.gateway,
|
||||
distance: form.distance ? parseInt(form.distance, 10) : undefined,
|
||||
comment: form.comment || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
||||
setForm({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
||||
setShowAdd(false);
|
||||
setAddError('');
|
||||
},
|
||||
onError: (err: unknown) => setAddError(errMsg(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (routeId: string) => devicesApi.deleteRoute(deviceId, routeId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
||||
setDeleteError('');
|
||||
},
|
||||
onError: (err: unknown) => setDeleteError(errMsg(err)),
|
||||
});
|
||||
|
||||
const isDynamic = (r: Record<string, string>) =>
|
||||
r['dynamic'] === 'true' || (r['flags'] && /[DCBOE]/.test(r['flags']));
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{deleteError && (
|
||||
<span className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />{deleteError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-2 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={() => { setShowAdd(!showAdd); setAddError(''); }} className="btn-primary flex items-center gap-2 text-sm">
|
||||
<Plus className="w-4 h-4" /> Add Route
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canWrite && showAdd && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">New Static Route</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label className="label">Destination (CIDR)</label>
|
||||
<input className="input font-mono" value={form.dst_address} onChange={e => setForm(f => ({ ...f, dst_address: e.target.value }))} placeholder="0.0.0.0/0" /></div>
|
||||
<div><label className="label">Gateway</label>
|
||||
<input className="input font-mono" value={form.gateway} onChange={e => setForm(f => ({ ...f, gateway: e.target.value }))} placeholder="192.168.1.1" /></div>
|
||||
<div><label className="label">Distance</label>
|
||||
<input type="number" className="input" value={form.distance} onChange={e => setForm(f => ({ ...f, distance: e.target.value }))} min={1} max={255} /></div>
|
||||
<div><label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => setForm(f => ({ ...f, comment: e.target.value }))} placeholder="Optional" /></div>
|
||||
</div>
|
||||
{addError && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{addError}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.dst_address || !form.gateway || addMutation.isPending} className="btn-primary flex items-center gap-2 text-sm">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Route
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{routes.length === 0 ? (
|
||||
<Empty text="No routes found." />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Destination</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Gateway</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Interface</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Dist.</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-4 py-2.5 w-10" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(routes as Record<string, string>[]).map((r, i) => {
|
||||
const active = r['active'] === 'true' || r['flags']?.includes('A');
|
||||
const dynamic = isDynamic(r);
|
||||
const routeId = r['.id'] || '';
|
||||
return (
|
||||
<tr key={routeId || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-mono text-gray-900 dark:text-white">{r['dst-address'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{r['gateway'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{r['interface'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{r['distance'] || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={clsx('text-xs font-medium', active ? 'text-green-600 dark:text-green-400' : 'text-gray-400')}>
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{dynamic && <span className="text-xs text-gray-400 dark:text-slate-500">(dynamic)</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 text-xs">{r['comment'] || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-2.5">
|
||||
{!dynamic && routeId && (
|
||||
<button
|
||||
onClick={() => { if (confirm(`Remove route to ${r['dst-address']}?`)) deleteMutation.mutate(routeId); }}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OSPF sub-tab ─────────────────────────────────────────────────────────────
|
||||
function OspfSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-ospf', deviceId],
|
||||
queryFn: () => dApi.getOspf(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAddInstance, setShowAddInstance] = useState(false);
|
||||
const [instanceForm, setInstanceForm] = useState({ name: '', 'router-id': '' });
|
||||
const [showAddArea, setShowAddArea] = useState(false);
|
||||
const [areaForm, setAreaForm] = useState({ name: '', area: '0.0.0.0', instance: '' });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addInstanceMutation = useMutation({
|
||||
mutationFn: () => dApi.addOspfInstance(deviceId, instanceForm),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }); setShowAddInstance(false); setInstanceForm({ name: '', 'router-id': '' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeInstanceMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeOspfInstance(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const addAreaMutation = useMutation({
|
||||
mutationFn: () => dApi.addOspfArea(deviceId, areaForm),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }); setShowAddArea(false); setAreaForm({ name: '', area: '0.0.0.0', instance: '' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeAreaMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeOspfArea(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
const instances = (data?.instances as Record<string, string>[]) || [];
|
||||
const areas = (data?.areas as Record<string, string>[]) || [];
|
||||
const interfaces= (data?.interfaces as Record<string, string>[]) || [];
|
||||
const neighbors = (data?.neighbors as Record<string, string>[]) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
|
||||
{/* Instances */}
|
||||
<div>
|
||||
<SectionHeader title="Instances" onRefresh={refetch} isFetching={isFetching} />
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAddInstance ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Name</label><input className="input" value={instanceForm.name} onChange={e => setInstanceForm(f => ({ ...f, name: e.target.value }))} placeholder="default" /></div>
|
||||
<div><label className="label">Router ID</label><input className="input font-mono" value={instanceForm['router-id']} onChange={e => setInstanceForm(f => ({ ...f, 'router-id': e.target.value }))} placeholder="1.2.3.4" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addInstanceMutation.mutate()} disabled={!instanceForm.name || addInstanceMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addInstanceMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add
|
||||
</button>
|
||||
<button onClick={() => setShowAddInstance(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAddInstance(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Instance</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{instances.length === 0 ? <Empty text="No OSPF instances." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Router ID</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">State</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{instances.map((inst, i) => (
|
||||
<tr key={inst['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{inst['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{inst['router-id'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{inst['state'] || inst['status'] || '—'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{inst['.id'] && <button onClick={() => { if (confirm('Remove instance?')) removeInstanceMutation.mutate(inst['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Areas */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Areas</h3>
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAddArea ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><label className="label">Name</label><input className="input" value={areaForm.name} onChange={e => setAreaForm(f => ({ ...f, name: e.target.value }))} placeholder="backbone" /></div>
|
||||
<div><label className="label">Area ID</label><input className="input font-mono" value={areaForm.area} onChange={e => setAreaForm(f => ({ ...f, area: e.target.value }))} placeholder="0.0.0.0" /></div>
|
||||
<div><label className="label">Instance</label><input className="input" value={areaForm.instance} onChange={e => setAreaForm(f => ({ ...f, instance: e.target.value }))} placeholder="default" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addAreaMutation.mutate()} disabled={!areaForm.name || addAreaMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addAreaMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add
|
||||
</button>
|
||||
<button onClick={() => setShowAddArea(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAddArea(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Area</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{areas.length === 0 ? <Empty text="No OSPF areas." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Area</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Instance</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{areas.map((a, i) => (
|
||||
<tr key={a['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{a['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{a['area'] || a['area-id'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{a['instance'] || '—'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{a['.id'] && <button onClick={() => { if (confirm('Remove area?')) removeAreaMutation.mutate(a['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interfaces (read-only) */}
|
||||
{interfaces.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Interfaces</h3>
|
||||
<KVTable rows={interfaces} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neighbors (read-only) */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Neighbors</h3>
|
||||
{neighbors.length === 0 ? <Empty text="No OSPF neighbors." /> : <KVTable rows={neighbors} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BGP sub-tab ──────────────────────────────────────────────────────────────
|
||||
function BgpSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-bgp', deviceId],
|
||||
queryFn: () => dApi.getBgp(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', 'remote.address': '', 'remote.as': '', 'local.role': 'ebgp' });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => dApi.addBgpConnection(deviceId, form),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-bgp', deviceId] }); setShowAdd(false); setForm({ name: '', 'remote.address': '', 'remote.as': '', 'local.role': 'ebgp' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeBgpConnection(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-bgp', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
const connections = (data?.connections as Record<string, string>[]) || [];
|
||||
const sessions = (data?.sessions as Record<string, string>[]) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
|
||||
{/* Connections */}
|
||||
<div>
|
||||
<SectionHeader title="BGP Connections" onRefresh={refetch} isFetching={isFetching} />
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAdd ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label className="label">Name</label><input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="peer-name" /></div>
|
||||
<div><label className="label">Remote Address</label><input className="input font-mono" value={form['remote.address']} onChange={e => setForm(f => ({ ...f, 'remote.address': e.target.value }))} placeholder="10.0.0.1" /></div>
|
||||
<div><label className="label">Remote AS</label><input className="input font-mono" value={form['remote.as']} onChange={e => setForm(f => ({ ...f, 'remote.as': e.target.value }))} placeholder="65000" /></div>
|
||||
<div><label className="label">Role</label>
|
||||
<select className="input" value={form['local.role']} onChange={e => setForm(f => ({ ...f, 'local.role': e.target.value }))}>
|
||||
<option value="ebgp">eBGP</option>
|
||||
<option value="ibgp">iBGP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.name || !form['remote.address'] || addMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Connection</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{connections.length === 0 ? <Empty text="No BGP connections configured." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Remote Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Remote AS</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Role</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{connections.map((c, i) => (
|
||||
<tr key={c['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{c['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{c['remote.address'] || c['remote-address'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{c['remote.as'] || c['remote-as'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{c['local.role'] || c['local-role'] || '—'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{c['.id'] && <button onClick={() => { if (confirm('Remove BGP connection?')) removeMutation.mutate(c['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sessions (read-only) */}
|
||||
{sessions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Active Sessions</h3>
|
||||
<KVTable rows={sessions} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Route Filters sub-tab ────────────────────────────────────────────────────
|
||||
function RouteFiltersSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-filters', deviceId],
|
||||
queryFn: () => dApi.getRouteFilters(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ chain: '', action: 'accept', prefix: '', comment: '' });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => dApi.addFilterRule(deviceId, { ...form, prefix: form.prefix || undefined }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-filters', deviceId] }); setShowAdd(false); setForm({ chain: '', action: 'accept', prefix: '', comment: '' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeFilterRule(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-filters', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
const rules = (data?.rules as Record<string, string>[]) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
<SectionHeader title="Route Filter Rules" onRefresh={refetch} isFetching={isFetching} />
|
||||
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAdd ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label className="label">Chain</label><input className="input" value={form.chain} onChange={e => setForm(f => ({ ...f, chain: e.target.value }))} placeholder="my-filter" /></div>
|
||||
<div><label className="label">Action</label>
|
||||
<select className="input" value={form.action} onChange={e => setForm(f => ({ ...f, action: e.target.value }))}>
|
||||
<option value="accept">accept</option>
|
||||
<option value="reject">reject</option>
|
||||
<option value="discard">discard</option>
|
||||
<option value="return">return</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label className="label">Prefix (optional)</label><input className="input font-mono" value={form.prefix} onChange={e => setForm(f => ({ ...f, prefix: e.target.value }))} placeholder="10.0.0.0/8" /></div>
|
||||
<div><label className="label">Comment</label><input className="input" value={form.comment} onChange={e => setForm(f => ({ ...f, comment: e.target.value }))} placeholder="Optional" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.chain || addMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Rule
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Rule</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rules.length === 0 ? <Empty text="No route filter rules." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">#</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Chain</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Action</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Prefix</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{rules.map((r, i) => (
|
||||
<tr key={r['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 text-xs">{i + 1}</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{r['chain'] || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={clsx('text-xs font-medium', r['action'] === 'accept' ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>
|
||||
{r['action'] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{r['prefix'] || r['dst-address'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">{r['comment'] || ''}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{r['.id'] && <button onClick={() => { if (confirm('Remove filter rule?')) removeMutation.mutate(r['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Routing Tables sub-tab ───────────────────────────────────────────────────
|
||||
function TablesSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: tables = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-tables', deviceId],
|
||||
queryFn: () => dApi.getRoutingTables(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', fib: false });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => dApi.addRoutingTable(deviceId, form),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-tables', deviceId] }); setShowAdd(false); setForm({ name: '', fib: false }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeRoutingTable(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-tables', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
<SectionHeader title="Routing Tables" onRefresh={refetch} isFetching={isFetching} />
|
||||
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAdd ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1"><label className="label">Table Name</label><input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="my-table" /></div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300 mb-1 cursor-pointer">
|
||||
<input type="checkbox" checked={form.fib} onChange={e => setForm(f => ({ ...f, fib: e.target.checked }))} className="rounded" />
|
||||
FIB
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.name || addMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Table
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Table</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tables.length === 0 ? <Empty text="No custom routing tables." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">FIB</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{tables.map((t, i) => (
|
||||
<tr key={t['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{t['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{t['fib'] === 'true' ? 'Yes' : 'No'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{t['.id'] && <button onClick={() => { if (confirm(`Remove table "${t['name']}"?`)) removeMutation.mutate(t['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main RoutingTab with sub-tabs ────────────────────────────────────────────
|
||||
export default function RoutingTab({ deviceId }: { deviceId: number }) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SubTab>('Routes');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Sub-tab bar */}
|
||||
<div className="flex gap-1 border-b border-gray-200 dark:border-slate-700">
|
||||
{SUB_TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveSubTab(tab)}
|
||||
className={clsx(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px',
|
||||
activeSubTab === tab
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sub-tab content */}
|
||||
{activeSubTab === 'Routes' && <RoutesSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'OSPF' && <OspfSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'BGP' && <BgpSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'Route Filters' && <RouteFiltersSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'Tables' && <TablesSubTab deviceId={deviceId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server,
|
||||
} from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { IpAddress, Interface } from '../../types';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
}
|
||||
|
||||
export default function SystemConfigTab({ deviceId }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
// ─── System config (identity, NTP, DNS) ──────────────────────────────────
|
||||
const { data: sysConfig, isLoading: sysLoading } = useQuery({
|
||||
queryKey: ['system-config', deviceId],
|
||||
queryFn: () => devicesApi.getSystemConfig(deviceId).then((r) => r.data),
|
||||
});
|
||||
|
||||
// ─── Clock ────────────────────────────────────────────────────────────────
|
||||
const { data: clockData, isLoading: clockLoading } = useQuery({
|
||||
queryKey: ['device-clock', deviceId],
|
||||
queryFn: () => devicesApi.getClock(deviceId).then((r) => r.data),
|
||||
});
|
||||
|
||||
// ─── Combined edit state ──────────────────────────────────────────────────
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [sysForm, setSysForm] = useState({
|
||||
identity: '',
|
||||
ntp_enabled: true,
|
||||
ntp_primary: '',
|
||||
ntp_secondary: '',
|
||||
dns_servers: '',
|
||||
dns_allow_remote: false,
|
||||
});
|
||||
const [clockForm, setClockForm] = useState({ date: '', time: '', timezone: '' });
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
if (!sysConfig) return;
|
||||
setSysForm({
|
||||
identity: sysConfig.identity,
|
||||
ntp_enabled: sysConfig.ntp.enabled !== 'no',
|
||||
ntp_primary: sysConfig.ntp['primary-ntp'] || '',
|
||||
ntp_secondary: sysConfig.ntp['secondary-ntp'] || '',
|
||||
dns_servers: sysConfig.dns['servers'] || '',
|
||||
dns_allow_remote: sysConfig.dns['allow-remote-requests'] === 'yes',
|
||||
});
|
||||
setClockForm({
|
||||
date: clockData?.date || '',
|
||||
time: clockData?.time || '',
|
||||
timezone: clockData?.timezone || '',
|
||||
});
|
||||
setSaveError('');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
try {
|
||||
await devicesApi.updateSystemConfig(deviceId, sysForm);
|
||||
await devicesApi.setClock(deviceId, clockForm);
|
||||
queryClient.invalidateQueries({ queryKey: ['system-config', deviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['device-clock', deviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['device', deviceId] });
|
||||
setEditing(false);
|
||||
setSaveSuccess('System configuration updated');
|
||||
setTimeout(() => setSaveSuccess(''), 3000);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSaveError(msg || 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── IP Addresses ────────────────────────────────────────────────────────
|
||||
const { data: ipAddresses = [], isLoading: ipLoading, refetch: refetchIp } = useQuery({
|
||||
queryKey: ['ip-addresses', deviceId],
|
||||
queryFn: () => devicesApi.getIpAddresses(deviceId).then((r) => r.data as IpAddress[]),
|
||||
});
|
||||
|
||||
const { data: interfaces = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data as Interface[]),
|
||||
});
|
||||
|
||||
const [newIp, setNewIp] = useState({ address: '', interface: '' });
|
||||
const [ipError, setIpError] = useState('');
|
||||
|
||||
const addIpMutation = useMutation({
|
||||
mutationFn: () => devicesApi.addIpAddress(deviceId, newIp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] });
|
||||
setNewIp({ address: '', interface: '' });
|
||||
setIpError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setIpError(msg || 'Failed to add IP address');
|
||||
},
|
||||
});
|
||||
|
||||
const removeIpMutation = useMutation({
|
||||
mutationFn: (id: string) => devicesApi.removeIpAddress(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }),
|
||||
});
|
||||
|
||||
// ─── Firmware check / install ─────────────────────────────────────────────
|
||||
const [updateInfo, setUpdateInfo] = useState<Record<string, string> | null>(null);
|
||||
const [updateChecking, setUpdateChecking] = useState(false);
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
const [installSuccess, setInstallSuccess] = useState('');
|
||||
|
||||
const checkUpdateMutation = useMutation({
|
||||
mutationFn: () => devicesApi.checkUpdate(deviceId),
|
||||
onMutate: () => { setUpdateChecking(true); setUpdateError(''); setUpdateInfo(null); setInstallSuccess(''); },
|
||||
onSuccess: (res) => { setUpdateInfo(res.data); setUpdateChecking(false); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setUpdateError(msg || 'Failed to check for updates');
|
||||
setUpdateChecking(false);
|
||||
},
|
||||
});
|
||||
|
||||
const installUpdateMutation = useMutation({
|
||||
mutationFn: () => devicesApi.installUpdate(deviceId),
|
||||
onSuccess: () => {
|
||||
setUpdateInfo(null);
|
||||
setInstallSuccess('Update installation initiated. The device will reboot shortly.');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setUpdateError(msg || 'Failed to install update');
|
||||
},
|
||||
});
|
||||
|
||||
// RouterOS 7 uses 'installed-version'; RouterOS 6 uses 'version'
|
||||
const installedVersion = updateInfo?.['installed-version'] || updateInfo?.['version'] || '';
|
||||
const latestVersion = updateInfo?.['latest-version'] || '';
|
||||
const statusSaysUpdate = updateInfo?.['status']?.toLowerCase().includes('new version') ||
|
||||
updateInfo?.['status']?.toLowerCase().includes('available');
|
||||
const hasUpdate = updateInfo && latestVersion && (
|
||||
(installedVersion && latestVersion !== installedVersion) ||
|
||||
(!installedVersion && statusSaysUpdate)
|
||||
);
|
||||
|
||||
const isLoading = sysLoading || clockLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── System Settings (identity, NTP, DNS, clock) ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" />
|
||||
System Settings
|
||||
</h3>
|
||||
{canWrite && !editing && (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
disabled={isLoading || !sysConfig}
|
||||
className="btn-secondary text-xs py-1.5"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Loading...
|
||||
</div>
|
||||
) : !sysConfig ? (
|
||||
<div className="text-sm text-gray-400">Could not load system configuration</div>
|
||||
) : editing ? (
|
||||
<div className="space-y-5">
|
||||
{/* Identity */}
|
||||
<div>
|
||||
<label className="label">Device Identity (Hostname)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.identity}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, identity: e.target.value }))}
|
||||
placeholder="router"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date / Time / Timezone */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
Date & Time
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="label">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={clockForm.date}
|
||||
onChange={(e) => setClockForm((f) => ({ ...f, date: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="input"
|
||||
value={clockForm.time}
|
||||
onChange={(e) => setClockForm((f) => ({ ...f, time: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Timezone</label>
|
||||
<input
|
||||
className="input"
|
||||
value={clockForm.timezone}
|
||||
onChange={(e) => setClockForm((f) => ({ ...f, timezone: e.target.value }))}
|
||||
placeholder="America/New_York"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">IANA name (e.g. UTC, Europe/London)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NTP */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
NTP Client
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ntp_enabled"
|
||||
checked={sysForm.ntp_enabled}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, ntp_enabled: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="ntp_enabled" className="text-sm text-gray-700 dark:text-slate-300">
|
||||
NTP enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Primary NTP server</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.ntp_primary}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, ntp_primary: e.target.value }))}
|
||||
placeholder="0.pool.ntp.org"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Secondary NTP server</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.ntp_secondary}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, ntp_secondary: e.target.value }))}
|
||||
placeholder="1.pool.ntp.org"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DNS */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
DNS
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="label">DNS Servers (comma-separated)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.dns_servers}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, dns_servers: e.target.value }))}
|
||||
placeholder="8.8.8.8,8.8.4.4"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dns_remote"
|
||||
checked={sysForm.dns_allow_remote}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, dns_allow_remote: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="dns_remote" className="text-sm text-gray-700 dark:text-slate-300">
|
||||
Allow remote DNS requests (act as DNS server)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{saving && <RefreshCw className="w-4 h-4 animate-spin" />}
|
||||
Save Changes
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-8 text-sm">
|
||||
{[
|
||||
['Identity', sysConfig.identity || '—'],
|
||||
['Date', clockData?.date || '—'],
|
||||
['Time', clockData?.time || '—'],
|
||||
['Timezone', clockData?.timezone || '—'],
|
||||
['NTP Enabled', sysConfig.ntp.enabled !== 'no' ? 'Yes' : 'No'],
|
||||
['Primary NTP', sysConfig.ntp['primary-ntp'] || '—'],
|
||||
['Secondary NTP', sysConfig.ntp['secondary-ntp'] || '—'],
|
||||
['DNS Servers', sysConfig.dns.servers || '—'],
|
||||
['Allow Remote DNS', sysConfig.dns['allow-remote-requests'] === 'yes' ? 'Yes' : 'No'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
|
||||
<span className="text-gray-500 dark:text-slate-400">{k}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-right">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveSuccess && (
|
||||
<div className="flex items-center gap-2 mt-3 text-sm text-green-600">
|
||||
<CheckCircle className="w-4 h-4" /> {saveSuccess}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── IP Addresses ── */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">IP Addresses</h3>
|
||||
<button
|
||||
onClick={() => refetchIp()}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', ipLoading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2 text-left">Address / Prefix</th>
|
||||
<th className="table-header px-4 py-2 text-left">Network</th>
|
||||
<th className="table-header px-4 py-2 text-left">Interface</th>
|
||||
<th className="table-header px-4 py-2 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{ipLoading ? (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">Loading...</td></tr>
|
||||
) : (ipAddresses as IpAddress[]).length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">No IP addresses</td></tr>
|
||||
) : (
|
||||
(ipAddresses as IpAddress[]).map((ip) => (
|
||||
<tr key={ip['.id']} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{ip.address}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{ip.network || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-slate-300">{ip.interface}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={clsx(
|
||||
'text-xs font-medium',
|
||||
ip.disabled === 'true' ? 'text-gray-400' : 'text-green-600 dark:text-green-400'
|
||||
)}>
|
||||
{ip.dynamic === 'true' ? 'dynamic' : ip.disabled === 'true' ? 'disabled' : 'active'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{canWrite && ip.dynamic !== 'true' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${ip.address} from ${ip.interface}?`)) {
|
||||
removeIpMutation.mutate(ip['.id']);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{canWrite && <div className="p-4 border-t border-gray-100 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="label">Address / Prefix (e.g. 192.168.1.1/24)</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={newIp.address}
|
||||
onChange={(e) => setNewIp((f) => ({ ...f, address: e.target.value }))}
|
||||
placeholder="192.168.1.1/24"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<label className="label">Interface</label>
|
||||
<select
|
||||
className="input"
|
||||
value={newIp.interface}
|
||||
onChange={(e) => setNewIp((f) => ({ ...f, interface: e.target.value }))}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{(interfaces as Interface[]).map((i) => (
|
||||
<option key={i.name} value={i.name}>{i.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addIpMutation.mutate()}
|
||||
disabled={!newIp.address || !newIp.interface || addIpMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{ipError && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {ipError}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Firmware Updates ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-blue-500" />
|
||||
Firmware Updates
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => checkUpdateMutation.mutate()}
|
||||
disabled={updateChecking}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', updateChecking && 'animate-spin')} />
|
||||
{updateChecking ? 'Checking...' : 'Check for Updates'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{updateError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500 mb-3">
|
||||
<AlertCircle className="w-4 h-4" /> {updateError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400 mb-3">
|
||||
<CheckCircle className="w-4 h-4" /> {installSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{[
|
||||
['Status', updateInfo['status'] || '—'],
|
||||
['Installed Version', installedVersion || '—'],
|
||||
['Latest Version', latestVersion || '—'],
|
||||
['Channel', updateInfo['channel'] || '—'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
|
||||
<span className="text-gray-500 dark:text-slate-400">{k}</span>
|
||||
<span className={clsx(
|
||||
'font-medium text-right',
|
||||
k === 'Status' && hasUpdate ? 'text-orange-500' : 'text-gray-900 dark:text-white'
|
||||
)}>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasUpdate ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-orange-500 flex-shrink-0" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium text-orange-700 dark:text-orange-400">
|
||||
Update available: {latestVersion}
|
||||
</p>
|
||||
<p className="text-orange-600 dark:text-orange-500 text-xs mt-0.5">
|
||||
The device will reboot after installing.
|
||||
</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Install RouterOS ${latestVersion} and reboot the device?`)) {
|
||||
installUpdateMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={installUpdateMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm flex-shrink-0"
|
||||
>
|
||||
{installUpdateMutation.isPending
|
||||
? <RefreshCw className="w-4 h-4 animate-spin" />
|
||||
: <Download className="w-4 h-4" />}
|
||||
Install & Reboot
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
RouterOS is up to date
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!updateInfo && !updateChecking && !installSuccess && (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
||||
Click "Check for Updates" to query the MikroTik update server.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Play, Loader2, CheckCircle, XCircle, Wifi, RotateCcw } from 'lucide-react';
|
||||
import { deviceToolsApi, devicesApi } from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function RunButton({ loading, onClick, label = 'Run' }: { loading: boolean; onClick: () => void; label?: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="btn-primary flex items-center gap-2 shrink-0"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{loading ? 'Running…' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBox({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
|
||||
<XCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultTable({ headers, rows }: { headers: string[]; rows: (string | null)[][] }) {
|
||||
if (!rows.length) return <p className="text-sm text-gray-400 dark:text-slate-500">No results.</p>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
{headers.map((h) => (
|
||||
<th key={h} className="px-3 py-1.5 text-left text-gray-500 dark:text-slate-400 font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50 table-zebra">
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-slate-700/20">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="px-3 py-1.5 text-gray-700 dark:text-slate-300">{cell ?? '—'}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ping ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PingTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) {
|
||||
const [address, setAddress] = useState('');
|
||||
const [count, setCount] = useState('4');
|
||||
const [iface, setIface] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, string>[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!address) return;
|
||||
setLoading(true); setError(''); setResults(null);
|
||||
try {
|
||||
const { data } = await deviceToolsApi.ping(deviceId, {
|
||||
address,
|
||||
count: parseInt(count) || 4,
|
||||
interface: iface || undefined,
|
||||
});
|
||||
setResults(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'Ping failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Split results into per-packet rows and summary (last row has packet-loss)
|
||||
const packets = results?.filter((r) => r['seq'] !== undefined) ?? [];
|
||||
const summary = results?.find((r) => r['sent'] !== undefined);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
className="input flex-1 min-w-48"
|
||||
placeholder="IP address or hostname"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && run()}
|
||||
/>
|
||||
<input
|
||||
className="input w-20"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
title="Packet count"
|
||||
/>
|
||||
{interfaces.length > 0 && (
|
||||
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
|
||||
<option value="">Any interface</option>
|
||||
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<RunButton loading={loading} onClick={run} />
|
||||
</div>
|
||||
|
||||
{error && <ErrorBox message={error} />}
|
||||
|
||||
{results && (
|
||||
<div className="space-y-3">
|
||||
<ResultTable
|
||||
headers={['Seq', 'Host', 'Time', 'TTL', 'Size', 'Status']}
|
||||
rows={packets.map((r) => [
|
||||
r['seq'] ?? null,
|
||||
r['host'] ?? null,
|
||||
r['time'] ? `${r['time']}ms` : null,
|
||||
r['ttl'] ?? null,
|
||||
r['size'] ? `${r['size']}B` : null,
|
||||
r['status'] || (r['time'] ? 'OK' : 'timeout'),
|
||||
])}
|
||||
/>
|
||||
{summary && (
|
||||
<div className="flex flex-wrap gap-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg text-xs font-mono text-gray-600 dark:text-slate-400">
|
||||
<span>Sent: <b className="text-gray-900 dark:text-white">{summary['sent']}</b></span>
|
||||
<span>Received: <b className="text-gray-900 dark:text-white">{summary['received']}</b></span>
|
||||
<span>Loss: <b className={clsx(
|
||||
summary['packet-loss'] === '0%' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
)}>{summary['packet-loss']}</b></span>
|
||||
{summary['avg-rtt'] && <span>Avg RTT: <b className="text-gray-900 dark:text-white">{summary['avg-rtt']}ms</b></span>}
|
||||
{summary['min-rtt'] && <span>Min: <b className="text-gray-900 dark:text-white">{summary['min-rtt']}ms</b></span>}
|
||||
{summary['max-rtt'] && <span>Max: <b className="text-gray-900 dark:text-white">{summary['max-rtt']}ms</b></span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Traceroute ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TracerouteTool({ deviceId }: { deviceId: number }) {
|
||||
const [address, setAddress] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, string>[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!address) return;
|
||||
setLoading(true); setError(''); setResults(null);
|
||||
try {
|
||||
const { data } = await deviceToolsApi.traceroute(deviceId, { address });
|
||||
setResults(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'Traceroute failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="IP address or hostname"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && run()}
|
||||
/>
|
||||
<RunButton loading={loading} onClick={run} />
|
||||
</div>
|
||||
{loading && (
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Tracing route — this may take up to 60 seconds…</p>
|
||||
)}
|
||||
{error && <ErrorBox message={error} />}
|
||||
{results && (
|
||||
<ResultTable
|
||||
headers={['Hop', 'Address', 'Status', 'Loss', 'Sent', 'Last', 'Avg', 'Best', 'Worst']}
|
||||
rows={results.map((r, i) => [
|
||||
String(i + 1),
|
||||
r['address'] || r['host'] || '* * *',
|
||||
r['status'] || '',
|
||||
r['loss'] ?? null,
|
||||
r['sent'] ?? null,
|
||||
r['last'] ? `${r['last']}ms` : null,
|
||||
r['avg'] ? `${r['avg']}ms` : null,
|
||||
r['best'] ? `${r['best']}ms` : null,
|
||||
r['worst'] ? `${r['worst']}ms` : null,
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── IP Scan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function IpScanTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) {
|
||||
const [addressRange, setAddressRange] = useState('');
|
||||
const [iface, setIface] = useState(interfaces[0] || '');
|
||||
const [rdns, setRdns] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, string>[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!addressRange || !iface) return;
|
||||
setLoading(true); setError(''); setResults(null);
|
||||
try {
|
||||
const { data } = await deviceToolsApi.ipScan(deviceId, { addressRange, interface: iface, rdns });
|
||||
setResults(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'IP scan failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
className="input flex-1 min-w-48"
|
||||
placeholder="Address range (e.g. 192.168.1.0/24)"
|
||||
value={addressRange}
|
||||
onChange={(e) => setAddressRange(e.target.value)}
|
||||
/>
|
||||
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
|
||||
<option value="">Select interface…</option>
|
||||
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rdns}
|
||||
onChange={(e) => setRdns(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-slate-600 text-blue-600 cursor-pointer"
|
||||
/>
|
||||
Reverse DNS
|
||||
</label>
|
||||
<RunButton loading={loading} onClick={run} label="Scan" />
|
||||
</div>
|
||||
{loading && (
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||
Scanning{rdns ? ' + resolving hostnames' : ''} — results appear when scan completes…
|
||||
</p>
|
||||
)}
|
||||
{error && <ErrorBox message={error} />}
|
||||
{results && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{results.length} host{results.length !== 1 ? 's' : ''} found</p>
|
||||
<ResultTable
|
||||
headers={rdns ? ['IP Address', 'Hostname', 'MAC Address'] : ['IP Address', 'MAC Address']}
|
||||
rows={results.map((r) =>
|
||||
rdns
|
||||
? [r['address'] ?? null, r['hostname'] || '—', r['mac-address'] ?? null]
|
||||
: [r['address'] ?? null, r['mac-address'] ?? null]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Wake-on-LAN ──────────────────────────────────────────────────────────────
|
||||
|
||||
function WolTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) {
|
||||
const [mac, setMac] = useState('');
|
||||
const [iface, setIface] = useState(interfaces[0] || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!mac || !iface) return;
|
||||
setLoading(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const { data } = await deviceToolsApi.wol(deviceId, { mac, interface: iface });
|
||||
setSuccess(data.message || 'Magic packet sent successfully');
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'WoL failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
className="input flex-1 min-w-48"
|
||||
placeholder="MAC address (e.g. AA:BB:CC:DD:EE:FF)"
|
||||
value={mac}
|
||||
onChange={(e) => setMac(e.target.value)}
|
||||
/>
|
||||
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
|
||||
<option value="">Select interface…</option>
|
||||
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
<RunButton loading={loading} onClick={run} label="Send Packet" />
|
||||
</div>
|
||||
{error && <ErrorBox message={error} />}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-sm text-green-700 dark:text-green-300">
|
||||
<CheckCircle className="w-4 h-4 shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reboot ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function RebootSection({ deviceId }: { deviceId: number }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!confirm('Are you sure you want to reboot this device? It will be temporarily unreachable.')) return;
|
||||
setLoading(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const { data } = await devicesApi.reboot(deviceId);
|
||||
setSuccess(data.message || 'Reboot command sent.');
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'Reboot failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<RotateCcw className="w-4 h-4 text-orange-500 shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Reboot Device</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Sends a reboot command to the device. It will be briefly unreachable while restarting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={loading}
|
||||
className="btn-secondary flex items-center gap-2 shrink-0 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800 hover:bg-orange-50 dark:hover:bg-orange-900/20"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RotateCcw className="w-4 h-4" />}
|
||||
{loading ? 'Rebooting…' : 'Reboot'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="mt-3"><ErrorBox message={error} /></div>}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 mt-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-sm text-green-700 dark:text-green-300">
|
||||
<CheckCircle className="w-4 h-4 shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ToolCard({ title, icon: Icon, children }: { title: string; icon: React.FC<{ className?: string }>; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Icon className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToolsTab({ deviceId }: { deviceId: number }) {
|
||||
const canWrite = useCanWrite();
|
||||
const { data: ifaceData = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data),
|
||||
});
|
||||
|
||||
const interfaceNames = ifaceData.map((i: { name: string }) => i.name).filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canWrite && <RebootSection deviceId={deviceId} />}
|
||||
|
||||
<ToolCard title="Ping" icon={({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
<path d="M5.636 5.636l2.122 2.122M16.243 16.243l2.121 2.121M5.636 18.364l2.122-2.122M16.243 7.757l2.121-2.121" />
|
||||
</svg>
|
||||
)}>
|
||||
<PingTool deviceId={deviceId} interfaces={interfaceNames} />
|
||||
</ToolCard>
|
||||
|
||||
<ToolCard title="Traceroute" icon={({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
)}>
|
||||
<TracerouteTool deviceId={deviceId} />
|
||||
</ToolCard>
|
||||
|
||||
<ToolCard title="IP Scan" icon={({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" />
|
||||
<circle cx="12" cy="10" r="3" /><path d="M6 10h.01M18 10h.01" />
|
||||
</svg>
|
||||
)}>
|
||||
<IpScanTool deviceId={deviceId} interfaces={interfaceNames} />
|
||||
</ToolCard>
|
||||
|
||||
{canWrite && (
|
||||
<ToolCard title="Wake-on-LAN" icon={Wifi}>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mb-3">
|
||||
Sends a WoL magic packet from this MikroTik device to wake a sleeping host on the network.
|
||||
</p>
|
||||
<WolTool deviceId={deviceId} interfaces={interfaceNames} />
|
||||
</ToolCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, AlertCircle, RefreshCw, Pencil, Check, X, Network } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { Vlan, SwitchPort } from '../../types';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface PortForm {
|
||||
tagged_ports: string;
|
||||
untagged_ports: string;
|
||||
}
|
||||
|
||||
export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number; onGoToPorts?: (bridgeName: string) => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: vlans = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['vlans', deviceId],
|
||||
queryFn: () => devicesApi.getVlans(deviceId).then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: portsData } = useQuery({
|
||||
queryKey: ['ports', deviceId],
|
||||
queryFn: () => devicesApi.getPorts(deviceId).then((r) => r.data),
|
||||
});
|
||||
const ports: SwitchPort[] = portsData?.ports ?? [];
|
||||
|
||||
// Find bridges where vlan-filtering is not enabled (stored as 'yes' from RouterOS or 'true' after toggle)
|
||||
const bridgesWithoutFiltering = ports.filter(
|
||||
(p) => p.type === 'bridge' && p.config_json?.['vlan-filtering'] !== 'yes' && p.config_json?.['vlan-filtering'] !== 'true'
|
||||
);
|
||||
|
||||
// ── Add form ──
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ vlan_id: '', bridge: '', tagged_ports: '', untagged_ports: '' });
|
||||
const [addError, setAddError] = useState('');
|
||||
|
||||
// ── Edit form ──
|
||||
const [editingVlan, setEditingVlan] = useState<Vlan | null>(null);
|
||||
const [editForm, setEditForm] = useState<PortForm>({ tagged_ports: '', untagged_ports: '' });
|
||||
const [editError, setEditError] = useState('');
|
||||
|
||||
// ── Delete error ──
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
const bridgeOptions = [...new Set((vlans as Vlan[]).map((v) => v.bridge).filter(Boolean))];
|
||||
|
||||
// ── Mutations ──
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const tagged = addForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const untagged = addForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return devicesApi.addVlan(deviceId, {
|
||||
bridge: addForm.bridge,
|
||||
vlan_id: parseInt(addForm.vlan_id, 10),
|
||||
tagged_ports: tagged,
|
||||
untagged_ports: untagged,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' });
|
||||
setShowAdd(false);
|
||||
setAddError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setAddError(msg || 'Failed to add VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!editingVlan) throw new Error('No VLAN selected');
|
||||
const tagged = editForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const untagged = editForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return devicesApi.updateVlan(deviceId, editingVlan.id, {
|
||||
tagged_ports: tagged,
|
||||
untagged_ports: untagged,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setEditingVlan(null);
|
||||
setEditError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setEditError(msg || 'Failed to update VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (vlanDbId: number) => devicesApi.deleteVlan(deviceId, vlanDbId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setDeleteError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setDeleteError(msg || 'Failed to delete VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = (vlan: Vlan) => {
|
||||
setEditingVlan(vlan);
|
||||
setEditError('');
|
||||
setShowAdd(false);
|
||||
setEditForm({
|
||||
tagged_ports: (vlan.tagged_ports ?? []).join(', '),
|
||||
untagged_ports: (vlan.untagged_ports ?? []).join(', '),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* VLAN Filtering warning */}
|
||||
{bridgesWithoutFiltering.length > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 px-4 py-3">
|
||||
<Network className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
<span className="font-semibold">VLAN Filtering is not enabled</span> on the Bridge interface
|
||||
{bridgesWithoutFiltering.length > 1
|
||||
? ` (${bridgesWithoutFiltering.map((b) => b.name).join(', ')})`
|
||||
: ` (${bridgesWithoutFiltering[0].name})`}.
|
||||
{' '}VLAN functionality is disabled — tagging and untagging rules will not be enforced.{' '}
|
||||
{onGoToPorts && (
|
||||
<button
|
||||
onClick={() => onGoToPorts(bridgesWithoutFiltering[0].name)}
|
||||
className="inline-flex items-center gap-1 font-semibold underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200 transition-colors"
|
||||
>
|
||||
Enable VLAN Filtering on the Bridge interface →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{deleteError && (
|
||||
<span className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />{deleteError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd(!showAdd);
|
||||
setEditingVlan(null);
|
||||
setAddError('');
|
||||
setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' });
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add VLAN
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add VLAN form */}
|
||||
{canWrite && showAdd && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">New Bridge VLAN</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<label className="label">VLAN ID (1–4094)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={addForm.vlan_id}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, vlan_id: e.target.value }))}
|
||||
min={1} max={4094}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Bridge</label>
|
||||
{bridgeOptions.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
value={addForm.bridge}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
|
||||
>
|
||||
{bridgeOptions.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.bridge}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
|
||||
placeholder="bridge1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Tagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.tagged_ports}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, tagged_ports: e.target.value }))}
|
||||
placeholder="ether1, ether2"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Untagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.untagged_ports}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, untagged_ports: e.target.value }))}
|
||||
placeholder="ether3"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
|
||||
</div>
|
||||
</div>
|
||||
{addError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {addError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => addMutation.mutate()}
|
||||
disabled={!addForm.vlan_id || !addForm.bridge || addMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||
Add VLAN
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit VLAN panel */}
|
||||
{canWrite && editingVlan && (
|
||||
<div className="card p-4 space-y-3 border-blue-300 dark:border-blue-600 border-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Edit VLAN{' '}
|
||||
<span className="font-mono text-blue-600 dark:text-blue-400">{editingVlan.vlan_id}</span>
|
||||
{editingVlan.bridge && (
|
||||
<span className="text-gray-400 dark:text-slate-500 font-normal"> on {editingVlan.bridge}</span>
|
||||
)}
|
||||
</h4>
|
||||
<button onClick={() => setEditingVlan(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">Tagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={editForm.tagged_ports}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, tagged_ports: e.target.value }))}
|
||||
placeholder="ether1, ether2"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Untagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={editForm.untagged_ports}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, untagged_ports: e.target.value }))}
|
||||
placeholder="ether3"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {editError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Check className="w-3.5 h-3.5" />}
|
||||
Save Changes
|
||||
</button>
|
||||
<button onClick={() => setEditingVlan(null)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VLANs table */}
|
||||
<div className="card overflow-hidden">
|
||||
{(vlans as Vlan[]).length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-slate-500">
|
||||
No VLANs configured on this device, or device not synced yet.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">VLAN ID</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Bridge</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Tagged Ports</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Untagged Ports</th>
|
||||
{canWrite && <th className="table-header px-4 py-2.5 w-16" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(vlans as Vlan[]).map((vlan) => (
|
||||
<tr
|
||||
key={vlan.id}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50 dark:hover:bg-slate-700/30',
|
||||
editingVlan?.id === vlan.id && 'bg-blue-50 dark:bg-blue-900/10'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-mono font-bold text-blue-600 dark:text-blue-400">
|
||||
{vlan.vlan_id}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{vlan.name || `VLAN ${vlan.vlan_id}`}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">
|
||||
{vlan.bridge || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(vlan.tagged_ports ?? []).map((p) => (
|
||||
<span key={p} className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs rounded font-mono">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{!(vlan.tagged_ports?.length) && '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(vlan.untagged_ports ?? []).map((p) => (
|
||||
<span key={p} className="px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 text-xs rounded font-mono">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{!(vlan.untagged_ports?.length) && '—'}
|
||||
</div>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => openEdit(vlan)}
|
||||
className={clsx(
|
||||
'p-1 rounded transition-colors',
|
||||
editingVlan?.id === vlan.id
|
||||
? 'text-blue-500 bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'text-gray-400 hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
title="Edit VLAN"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove VLAN ${vlan.vlan_id} from ${vlan.bridge}?`)) {
|
||||
deleteMutation.mutate(vlan.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Delete VLAN"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
prefill?: { name?: string; ip_address?: string };
|
||||
}
|
||||
|
||||
export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: prefill?.name || '',
|
||||
ip_address: prefill?.ip_address || '',
|
||||
api_port: '8728',
|
||||
api_username: 'admin',
|
||||
api_password: '',
|
||||
ssh_port: '22',
|
||||
ssh_username: '',
|
||||
ssh_password: '',
|
||||
device_type: 'router',
|
||||
notes: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name || !form.ip_address || !form.api_username || !form.api_password) {
|
||||
setError('Name, IP address, username, and password are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await devicesApi.create({
|
||||
...form,
|
||||
api_port: parseInt(form.api_port),
|
||||
ssh_port: parseInt(form.ssh_port),
|
||||
device_type: form.device_type as import('../../types').DeviceType,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Failed to add device');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Add Device</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{/* Basic info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="label">Device Name *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder="e.g. Core Switch"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">IP Address *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ip_address}
|
||||
onChange={(e) => set('ip_address', e.target.value)}
|
||||
placeholder="192.168.1.1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Device Type</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.device_type}
|
||||
onChange={(e) => set('device_type', e.target.value)}
|
||||
>
|
||||
<option value="router">Router</option>
|
||||
<option value="switch">Switch</option>
|
||||
<option value="wireless_ap">Wireless AP</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API credentials */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||
RouterOS API Credentials
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Username *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.api_username}
|
||||
onChange={(e) => set('api_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.api_port}
|
||||
onChange={(e) => set('api_port', e.target.value)}
|
||||
placeholder="8728"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">Password *</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.api_password}
|
||||
onChange={(e) => set('api_password', e.target.value)}
|
||||
placeholder="RouterOS API password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH credentials (optional) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider select-none">
|
||||
SSH Credentials (optional, for backup/restore)
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">SSH Username</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ssh_username}
|
||||
onChange={(e) => set('ssh_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">SSH Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.ssh_port}
|
||||
onChange={(e) => set('ssh_port', e.target.value)}
|
||||
placeholder="22"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">SSH Password</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.ssh_password}
|
||||
onChange={(e) => set('ssh_password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<label className="label">Notes (optional)</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
placeholder="Any notes about this device..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
Testing connection and collecting device data...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="btn-primary flex items-center gap-2">
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import TopBar from './TopBar';
|
||||
|
||||
export default function AppLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-slate-900 overflow-hidden">
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar mobileOpen={sidebarOpen} onMobileClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<TopBar onMenuClick={() => setSidebarOpen((o) => !o)} />
|
||||
<main className="flex-1 overflow-auto p-3 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Router, Wifi, AlertTriangle, X } from 'lucide-react';
|
||||
import { searchApi } from '../../services/api';
|
||||
import { format } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type SearchDevice = {
|
||||
id: number; name: string; ip_address: string;
|
||||
model?: string; device_type: string; status: string;
|
||||
};
|
||||
type SearchClient = {
|
||||
mac_address: string; hostname?: string; ip_address?: string;
|
||||
device_id: number; device_name?: string; active: boolean;
|
||||
};
|
||||
type SearchEvent = {
|
||||
id: number; message: string; severity: string;
|
||||
event_time: string; topic?: string; device_name?: string;
|
||||
};
|
||||
type SearchResults = { devices: SearchDevice[]; clients: SearchClient[]; events: SearchEvent[] };
|
||||
|
||||
const SEVERITY_ICON_CLS: Record<string, string> = {
|
||||
error: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
critical: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
warning: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400',
|
||||
info: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Debounce the query input
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(query), 280);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
// Fetch when debounced query is long enough
|
||||
useEffect(() => {
|
||||
if (debouncedQuery.length < 2) { setResults(null); return; }
|
||||
setLoading(true);
|
||||
searchApi.search(debouncedQuery)
|
||||
.then((r) => { setResults(r.data); setHighlighted(0); })
|
||||
.catch(() => setResults(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [debouncedQuery]);
|
||||
|
||||
// Ctrl+K / Cmd+K global shortcut
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
// Flat list for keyboard navigation index tracking
|
||||
const deviceCount = results?.devices.length ?? 0;
|
||||
const clientCount = results?.clients.length ?? 0;
|
||||
const eventCount = results?.events.length ?? 0;
|
||||
const totalCount = deviceCount + clientCount + eventCount;
|
||||
|
||||
const goToResult = useCallback((kind: 'device' | 'client' | 'event', id: string | number) => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
if (kind === 'device') navigate(`/devices/${id}`);
|
||||
else if (kind === 'client') navigate('/clients');
|
||||
else navigate('/events');
|
||||
}, [navigate]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!open || totalCount === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => (h + 1) % totalCount);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => (h - 1 + totalCount) % totalCount);
|
||||
} else if (e.key === 'Enter' && results) {
|
||||
e.preventDefault();
|
||||
if (highlighted < deviceCount) {
|
||||
goToResult('device', results.devices[highlighted].id);
|
||||
} else if (highlighted < deviceCount + clientCount) {
|
||||
goToResult('client', results.clients[highlighted - deviceCount].mac_address);
|
||||
} else {
|
||||
goToResult('event', results.events[highlighted - deviceCount - clientCount].id);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults = totalCount > 0;
|
||||
const showDropdown = open && query.length >= 2;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-1 max-w-lg">
|
||||
{/* Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search devices, clients, events… (Ctrl+K)"
|
||||
className="w-full pl-9 pr-8 py-1.5 text-sm bg-gray-100 dark:bg-slate-700/80 border border-transparent focus:border-blue-500 focus:bg-white dark:focus:bg-slate-800 rounded-lg outline-none transition-colors text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-slate-500"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-2xl z-50 overflow-hidden max-h-[500px] flex flex-col">
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-sm text-center text-gray-400 dark:text-slate-500">
|
||||
Searching…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasResults && (
|
||||
<div className="px-4 py-8 text-sm text-center text-gray-400 dark:text-slate-500">
|
||||
No results for <span className="font-medium text-gray-600 dark:text-slate-300">"{query}"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && hasResults && results && (
|
||||
<>
|
||||
{/* ── Devices ── */}
|
||||
{results.devices.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
Devices
|
||||
</div>
|
||||
{results.devices.map((d, i) => (
|
||||
<button
|
||||
key={d.id}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
highlighted === i
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(i)}
|
||||
onClick={() => goToResult('device', d.id)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
d.status === 'online'
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-gray-100 dark:bg-slate-700'
|
||||
)}>
|
||||
<Router className={clsx(
|
||||
'w-3.5 h-3.5',
|
||||
d.status === 'online' ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-slate-500'
|
||||
)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{d.name}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono">
|
||||
{d.ip_address}{d.model ? ` · ${d.model}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'text-xs px-1.5 py-0.5 rounded font-medium flex-shrink-0',
|
||||
d.status === 'online'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-slate-700 dark:text-slate-400'
|
||||
)}>
|
||||
{d.status}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Clients ── */}
|
||||
{results.clients.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
Clients
|
||||
</div>
|
||||
{results.clients.map((c, i) => (
|
||||
<button
|
||||
key={c.mac_address}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
highlighted === deviceCount + i
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(deviceCount + i)}
|
||||
onClick={() => goToResult('client', c.mac_address)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<Wifi className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{c.hostname || c.mac_address}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono">
|
||||
{c.hostname ? `${c.mac_address} · ` : ''}
|
||||
{c.ip_address ? `${c.ip_address} · ` : ''}
|
||||
{c.device_name || ''}
|
||||
</div>
|
||||
</div>
|
||||
{!c.active && (
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500 flex-shrink-0">inactive</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Events ── */}
|
||||
{results.events.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
Events
|
||||
</div>
|
||||
{results.events.map((ev, i) => (
|
||||
<button
|
||||
key={ev.id}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
highlighted === deviceCount + clientCount + i
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(deviceCount + clientCount + i)}
|
||||
onClick={() => goToResult('event', ev.id)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
SEVERITY_ICON_CLS[ev.severity] || SEVERITY_ICON_CLS.info
|
||||
)}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{ev.message}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{ev.device_name ? `${ev.device_name} · ` : ''}
|
||||
{format(new Date(ev.event_time), 'MMM d, HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'text-xs px-1.5 py-0.5 rounded font-medium flex-shrink-0',
|
||||
SEVERITY_ICON_CLS[ev.severity] || SEVERITY_ICON_CLS.info
|
||||
)}>
|
||||
{ev.severity}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer keyboard hints */}
|
||||
{hasResults && (
|
||||
<div className="flex-shrink-0 px-3 py-2 border-t border-gray-100 dark:border-slate-700 flex items-center gap-4 text-xs text-gray-400 dark:text-slate-500">
|
||||
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">↑↓</kbd> navigate</span>
|
||||
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">↵</kbd> open</span>
|
||||
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">Esc</kbd> close</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
|
||||
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
|
||||
Server, Globe, Clock, Shield,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { APP_VERSION } from '../../version';
|
||||
import { devicesApi } from '../../services/api';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/devices', icon: Router, label: 'Devices' },
|
||||
{ to: '/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/events', icon: Bell, label: 'Events' },
|
||||
{ to: '/topology', icon: GitBranch, label: 'Topology' },
|
||||
{ to: '/backups', icon: HardDrive, label: 'Backups' },
|
||||
];
|
||||
|
||||
const switchSubItems = [
|
||||
{ to: '/switches', icon: Layers, label: 'Overview' },
|
||||
{ to: '/switches/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const routerSubItems = [
|
||||
{ to: '/routers', icon: Router, label: 'Overview' },
|
||||
{ to: '/routers/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const wirelessSubItems = [
|
||||
{ to: '/wireless', icon: Wifi, label: 'Overview' },
|
||||
{ to: '/wireless/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const networkServicesSubItems = [
|
||||
{ to: '/network-services', icon: Network, label: 'Overview' },
|
||||
{ to: '/network-services/dhcp', icon: Server, label: 'DHCP' },
|
||||
{ to: '/network-services/dns', icon: Globe, label: 'DNS' },
|
||||
{ to: '/network-services/ntp', icon: Clock, label: 'NTP' },
|
||||
{ to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
mobileOpen: boolean;
|
||||
onMobileClose: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const hasSwitches = devices.some(d => d.device_type === 'switch');
|
||||
const hasRouters = devices.some(d => d.device_type === 'router');
|
||||
const hasWireless = devices.some(d => d.device_type === 'wireless_ap');
|
||||
|
||||
const switchesActive = location.pathname.startsWith('/switches');
|
||||
const [switchesOpen, setSwitchesOpen] = useState(switchesActive);
|
||||
const effectiveSwitchesOpen = switchesOpen || switchesActive;
|
||||
|
||||
const routersActive = location.pathname.startsWith('/routers');
|
||||
const [routersOpen, setRoutersOpen] = useState(routersActive);
|
||||
const effectiveRoutersOpen = routersOpen || routersActive;
|
||||
|
||||
const wirelessActive = location.pathname.startsWith('/wireless');
|
||||
const [wirelessOpen, setWirelessOpen] = useState(wirelessActive);
|
||||
const effectiveWirelessOpen = wirelessOpen || wirelessActive;
|
||||
|
||||
const networkServicesActive = location.pathname.startsWith('/network-services');
|
||||
const [networkServicesOpen, setNetworkServicesOpen] = useState(networkServicesActive);
|
||||
const effectiveNetworkServicesOpen = networkServicesOpen || networkServicesActive;
|
||||
|
||||
// On mobile the sidebar is always expanded (never icon-only)
|
||||
const isCollapsed = collapsed;
|
||||
|
||||
const handleNavClick = () => {
|
||||
// Close mobile drawer when navigating
|
||||
onMobileClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
// Base styles
|
||||
'flex-shrink-0 bg-slate-100 dark:bg-slate-900 border-r border-gray-200 dark:border-slate-700 flex flex-col transition-all duration-200',
|
||||
// Mobile: fixed drawer that slides in/out
|
||||
'fixed inset-y-0 left-0 z-50',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
// Desktop: static in flex layout, normal collapse behaviour
|
||||
'md:relative md:translate-x-0',
|
||||
isCollapsed ? 'md:w-14' : 'md:w-56',
|
||||
// Mobile always full width
|
||||
'w-64',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center border-b border-gray-200 dark:border-slate-700 py-5',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-4 gap-3' : 'gap-3 px-4'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Network className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
{/* Always show text on mobile; respect collapsed on desktop */}
|
||||
<div className={clsx(isCollapsed && 'md:hidden')}>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white leading-none">Mikrotik</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 leading-none">Manager</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-slate-500 mt-1 leading-none">{APP_VERSION}</div>
|
||||
</div>
|
||||
{/* Mobile close button */}
|
||||
<button
|
||||
onClick={onMobileClose}
|
||||
className="ml-auto p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-slate-300 md:hidden"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
title={isCollapsed ? label : undefined}
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className={clsx(isCollapsed && 'md:hidden')}>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Switches group */}
|
||||
{hasSwitches && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/switches"
|
||||
title="Switches"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || switchesActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Layers className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSwitchesOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
switchesActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Layers className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Switches</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveSwitchesOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveSwitchesOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{switchSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Routers group */}
|
||||
{hasRouters && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/routers"
|
||||
title="Routers"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || routersActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Router className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setRoutersOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
routersActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Router className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Routers</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveRoutersOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveRoutersOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{routerSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Wireless group */}
|
||||
{hasWireless && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/wireless"
|
||||
title="Wireless"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || wirelessActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Wifi className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setWirelessOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
wirelessActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Wifi className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Wireless</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveWirelessOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveWirelessOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{wirelessSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{/* Network Services group — always shown */}
|
||||
{isCollapsed ? (
|
||||
<NavLink
|
||||
to="/network-services"
|
||||
title="Network Services"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || networkServicesActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Network className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setNetworkServicesOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
networkServicesActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Network className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Network Services</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveNetworkServicesOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveNetworkServicesOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{networkServicesSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Settings + collapse toggle */}
|
||||
<div className="px-2 py-3 border-t border-gray-200 dark:border-slate-700 space-y-0.5">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
title={isCollapsed ? 'Settings' : undefined}
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
<span className={clsx(isCollapsed && 'md:hidden')}>Settings</span>
|
||||
</NavLink>
|
||||
|
||||
{/* Collapse toggle — desktop only */}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={clsx(
|
||||
'hidden md:flex w-full items-center rounded-lg text-sm font-medium transition-colors duration-150 text-gray-500 dark:text-slate-500 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-700 dark:hover:text-slate-300',
|
||||
isCollapsed ? 'justify-center px-2 py-2.5' : 'gap-3 px-3 py-2.5'
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<><ChevronLeft className="w-4 h-4 flex-shrink-0" />Collapse</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Sun, Moon, LogOut, User, Menu } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export default function TopBar({ onMenuClick }: TopBarProps) {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { theme, toggleTheme } = useThemeStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-14 flex-shrink-0 flex items-center gap-2 sm:gap-4 px-3 sm:px-6 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors flex-shrink-0"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<GlobalSearch />
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0 ml-auto">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="w-4 h-4" />
|
||||
) : (
|
||||
<Sun className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* User info — hide label on very small screens */}
|
||||
<div className="flex items-center gap-2 px-2 sm:px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-slate-700">
|
||||
<User className="w-3.5 h-3.5 text-gray-500 dark:text-slate-400 flex-shrink-0" />
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-700 dark:text-slate-200">
|
||||
{user?.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export function useCanWrite(): boolean {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
return user?.role !== 'viewer';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
let globalSocket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket | null {
|
||||
return globalSocket;
|
||||
}
|
||||
|
||||
export function useSocket(
|
||||
events: Record<string, (data: unknown) => void>
|
||||
): void {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const eventsRef = useRef(events);
|
||||
eventsRef.current = events;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
if (!globalSocket) {
|
||||
globalSocket = io('/', {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket'],
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
const socket = globalSocket;
|
||||
const handlers: Array<[string, (d: unknown) => void]> = [];
|
||||
|
||||
for (const [event, handler] of Object.entries(eventsRef.current)) {
|
||||
const wrapped = (data: unknown) => handler(data);
|
||||
socket.on(event, wrapped);
|
||||
handlers.push([event, wrapped]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [event, handler] of handlers) {
|
||||
socket.off(event, handler);
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-bg: theme('colors.white');
|
||||
--color-surface: theme('colors.gray.50');
|
||||
--color-border: theme('colors.gray.200');
|
||||
--color-text: theme('colors.gray.900');
|
||||
--color-text-muted: theme('colors.gray.500');
|
||||
--color-sidebar-bg: theme('colors.slate.100');
|
||||
--color-sidebar-text: theme('colors.slate.700');
|
||||
--color-sidebar-active: theme('colors.blue.600');
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-bg: #0f172a;
|
||||
--color-surface: #1e293b;
|
||||
--color-border: #334155;
|
||||
--color-text: #f1f5f9;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-sidebar-bg: #0f172a;
|
||||
--color-sidebar-text: #cbd5e1;
|
||||
--color-sidebar-active: #3b82f6;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-200 dark:border-slate-700;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5 h-1.5;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-slate-600 rounded-full;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600
|
||||
text-gray-700 dark:text-slate-200 px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-colors duration-150;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-colors duration-150;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600
|
||||
rounded-lg text-sm text-gray-900 dark:text-slate-100 placeholder-gray-400
|
||||
dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
focus:border-transparent;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400;
|
||||
}
|
||||
.status-offline {
|
||||
@apply bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400;
|
||||
}
|
||||
.status-unknown {
|
||||
@apply bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400;
|
||||
}
|
||||
|
||||
.severity-error {
|
||||
@apply bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400;
|
||||
}
|
||||
.severity-warning {
|
||||
@apply bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400;
|
||||
}
|
||||
.severity-info {
|
||||
@apply bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400;
|
||||
}
|
||||
|
||||
/* Zebra striping — uses :where() so explicit bg classes on <tr> always win */
|
||||
:where(.table-zebra > tr:nth-child(even)) {
|
||||
@apply bg-gray-50 dark:bg-slate-700/30;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
// Initialize theme from storage immediately
|
||||
const storedTheme = (() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('mikrotik-theme');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.state?.theme;
|
||||
}
|
||||
} catch {}
|
||||
return 'light';
|
||||
})();
|
||||
if (storedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HardDrive, Plus, Download, RotateCcw, Trash2, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { backupsApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import { format } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function formatBytes(bytes?: number): string {
|
||||
if (!bytes) return '—';
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
export default function BackupsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [selectedDevice, setSelectedDevice] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [restoreConfirm, setRestoreConfirm] = useState<number | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { data: backups = [], isLoading } = useQuery({
|
||||
queryKey: ['backups'],
|
||||
queryFn: () => backupsApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => backupsApi.create(parseInt(selectedDevice), notes || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||
setShowCreateForm(false);
|
||||
setSelectedDevice('');
|
||||
setNotes('');
|
||||
setError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Backup failed');
|
||||
},
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (id: number) => backupsApi.restore(id),
|
||||
onSuccess: () => setRestoreConfirm(null),
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Restore failed');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => backupsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
const downloadBackup = async (id: number, filename: string) => {
|
||||
const res = await backupsApi.download(id);
|
||||
const url = URL.createObjectURL(res.data as Blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Backups
|
||||
{backups.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||||
({backups.length} total)
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Backup
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create backup form */}
|
||||
{showCreateForm && (
|
||||
<div className="card p-5 border-blue-200 dark:border-blue-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-4">New Backup</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Device</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
>
|
||||
<option value="">Select a device...</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name} ({d.ip_address})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Notes (optional)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Pre-upgrade backup..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
onClick={() => { setShowCreateForm(false); setError(''); }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!selectedDevice || createMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<HardDrive className="w-4 h-4" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Backup'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-2">
|
||||
Backup uses SSH export. Make sure SSH credentials are configured for this device.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-4 text-center">
|
||||
<HardDrive className="w-16 h-16 text-gray-300 dark:text-slate-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 dark:text-slate-300">No backups yet</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Create a backup to save your device configurations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Device</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Filename</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Size</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Created</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Notes</th>
|
||||
<th className="table-header px-4 py-2.5 text-left w-32">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{backups.map((backup) => (
|
||||
<tr key={backup.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{backup.device_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{backup.filename}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{formatBytes(backup.size_bytes)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{format(new Date(backup.created_at), 'MMM d, yyyy HH:mm')}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500 italic">
|
||||
{backup.notes || ''}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => downloadBackup(backup.id, backup.filename)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{canWrite && (restoreConfirm === backup.id ? (
|
||||
<div className="flex gap-1 text-xs">
|
||||
<button
|
||||
onClick={() => restoreMutation.mutate(backup.id)}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="text-orange-500 hover:text-orange-600 font-medium"
|
||||
>
|
||||
{restoreMutation.isPending ? '...' : 'Restore?'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(backup.id)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors"
|
||||
title="Restore"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{canWrite && (deleteConfirm === backup.id ? (
|
||||
<div className="flex gap-1 text-xs">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(backup.id)}
|
||||
className="text-red-500 hover:text-red-600 font-medium"
|
||||
>
|
||||
Delete?
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(backup.id)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, Wifi, Network, Router, Layers, Activity,
|
||||
Power, Save, Clock, ChevronRight, AlertCircle, Pencil, X,
|
||||
} from 'lucide-react';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
BarChart, Bar, Cell, ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { clientsApi } from '../services/api';
|
||||
import type { ClientDetail } from '../services/api';
|
||||
import type { SignalPoint } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function signalColor(dbm: number | null): string {
|
||||
if (dbm === null) return 'text-gray-400 dark:text-slate-500';
|
||||
if (dbm >= -55) return 'text-green-600 dark:text-green-400';
|
||||
if (dbm >= -65) return 'text-lime-600 dark:text-lime-400';
|
||||
if (dbm >= -75) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
function signalLabel(dbm: number | null): string {
|
||||
if (dbm === null) return '—';
|
||||
if (dbm >= -55) return 'Excellent';
|
||||
if (dbm >= -65) return 'Good';
|
||||
if (dbm >= -75) return 'Fair';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
function deviceIcon(type?: string) {
|
||||
if (type === 'wireless_ap') return Wifi;
|
||||
if (type === 'switch') return Layers;
|
||||
return Router;
|
||||
}
|
||||
|
||||
// ─── Connectivity Timeline ────────────────────────────────────────────────────
|
||||
|
||||
function ConnectivityTimeline({ mac }: { mac: string }) {
|
||||
const [range, setRange] = useState<'2h' | '24h' | '7d'>('24h');
|
||||
|
||||
const { data: points = [], isLoading } = useQuery({
|
||||
queryKey: ['client-presence', mac, range],
|
||||
queryFn: () => clientsApi.getPresence(mac, range).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const ranges = [
|
||||
{ value: '2h', label: '2h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '7d', label: '7d' },
|
||||
] as const;
|
||||
|
||||
const onlineCount = points.filter(p => p.online > 0).length;
|
||||
const uptimePct = points.length > 0 ? Math.round((onlineCount / points.length) * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white">Connectivity</h2>
|
||||
{uptimePct !== null && (
|
||||
<span className={clsx(
|
||||
'text-xs font-medium px-1.5 py-0.5 rounded',
|
||||
uptimePct >= 90
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: uptimePct >= 70
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
)}>
|
||||
{uptimePct}% uptime
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setRange(r.value)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-8 bg-gray-100 dark:bg-slate-700 rounded animate-pulse" />
|
||||
) : points.length === 0 ? (
|
||||
<div className="h-8 flex items-center justify-center text-xs text-gray-400 dark:text-slate-500 bg-gray-50 dark:bg-slate-800 rounded">
|
||||
No presence data yet — data accumulates as the client is polled
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-8 rounded overflow-hidden gap-px">
|
||||
{points.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={`${format(parseISO(p.time), 'MMM d HH:mm')} — ${p.online ? 'Online' : 'Offline'}`}
|
||||
className={clsx(
|
||||
'flex-1 transition-colors',
|
||||
p.online ? 'bg-green-500 dark:bg-green-600' : 'bg-gray-200 dark:bg-slate-700'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-gray-400 dark:text-slate-500">
|
||||
<span>{points.length > 0 ? format(parseISO(points[0].time), range === '7d' ? 'MMM d' : 'HH:mm') : ''}</span>
|
||||
<span>now</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-gray-500 dark:text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-sm bg-green-500 inline-block" />
|
||||
Online
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-sm bg-gray-200 dark:bg-slate-700 inline-block" />
|
||||
Offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Connection Diagram ───────────────────────────────────────────────────────
|
||||
|
||||
function ConnectionDiagram({ client }: { client: ClientDetail }) {
|
||||
const ClientIcon = client.client_type === 'wireless' ? Wifi : Network;
|
||||
const DeviceIcon = deviceIcon(client.device_type);
|
||||
const UpstreamIcon = client.upstream_device_type ? deviceIcon(client.upstream_device_type) : Router;
|
||||
|
||||
const clientName = client.custom_name || client.hostname || client.mac_address;
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
Connection Path
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Client node */}
|
||||
<div className="flex flex-col items-center gap-1 min-w-[80px]">
|
||||
<div className={clsx(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center',
|
||||
client.active
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-gray-100 dark:bg-slate-700'
|
||||
)}>
|
||||
<ClientIcon className={clsx(
|
||||
'w-5 h-5',
|
||||
client.active ? 'text-green-600 dark:text-green-400' : 'text-gray-400'
|
||||
)} />
|
||||
</div>
|
||||
<span className="text-[11px] text-center text-gray-700 dark:text-slate-300 font-medium max-w-[90px] truncate">
|
||||
{clientName}
|
||||
</span>
|
||||
{client.ip_address && (
|
||||
<span className="text-[10px] font-mono text-gray-400 dark:text-slate-500">
|
||||
{client.ip_address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 dark:text-slate-600 flex-shrink-0" />
|
||||
|
||||
{/* Connected device (AP/switch) */}
|
||||
<div className="flex flex-col items-center gap-1 min-w-[80px]">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<DeviceIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-[11px] text-center text-gray-700 dark:text-slate-300 font-medium max-w-[90px] truncate">
|
||||
{client.device_name || 'Unknown'}
|
||||
</span>
|
||||
{client.ssid && (
|
||||
<span className="text-[10px] text-blue-500 dark:text-blue-400 font-medium">
|
||||
{client.ssid}
|
||||
</span>
|
||||
)}
|
||||
{client.interface_name && !client.ssid && (
|
||||
<span className="text-[10px] font-mono text-gray-400 dark:text-slate-500">
|
||||
{client.interface_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upstream device (if known) */}
|
||||
{client.upstream_device_name && (
|
||||
<>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 dark:text-slate-600 flex-shrink-0" />
|
||||
<div className="flex flex-col items-center gap-1 min-w-[80px]">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<UpstreamIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<span className="text-[11px] text-center text-gray-700 dark:text-slate-300 font-medium max-w-[90px] truncate">
|
||||
{client.upstream_device_name}
|
||||
</span>
|
||||
{client.upstream_device_ip && (
|
||||
<span className="text-[10px] font-mono text-gray-400 dark:text-slate-500">
|
||||
{client.upstream_device_ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Client Details Card ──────────────────────────────────────────────────────
|
||||
|
||||
function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrite: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [notes, setNotes] = useState(client.comment || '');
|
||||
const [notesChanged, setNotesChanged] = useState(false);
|
||||
const [wolStatus, setWolStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameValue, setNameValue] = useState(client.custom_name || '');
|
||||
|
||||
const nameMutation = useMutation({
|
||||
mutationFn: () => clientsApi.updateHostname(client.mac_address, nameValue),
|
||||
onSuccess: () => {
|
||||
// Invalidate everywhere names are shown
|
||||
qc.invalidateQueries({ queryKey: ['client-detail', client.mac_address] });
|
||||
qc.invalidateQueries({ queryKey: ['clients'] });
|
||||
qc.invalidateQueries({ queryKey: ['wireless-clients-page'] });
|
||||
setEditingName(false);
|
||||
},
|
||||
});
|
||||
|
||||
const notesMutation = useMutation({
|
||||
mutationFn: () => clientsApi.updateNotes(client.mac_address, notes),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['client-detail', client.mac_address] });
|
||||
setNotesChanged(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleWol = async () => {
|
||||
setWolStatus('sending');
|
||||
try {
|
||||
await clientsApi.wol(client.mac_address);
|
||||
setWolStatus('sent');
|
||||
setTimeout(() => setWolStatus('idle'), 3000);
|
||||
} catch {
|
||||
setWolStatus('error');
|
||||
setTimeout(() => setWolStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const DetailRow = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex items-start justify-between gap-4 py-2 border-b border-gray-100 dark:border-slate-700/50 last:border-0">
|
||||
<span className="text-xs text-gray-500 dark:text-slate-400 shrink-0 pt-0.5">{label}</span>
|
||||
<div className="text-xs text-gray-800 dark:text-slate-200 text-right">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white">Client Details</h2>
|
||||
|
||||
<div className="space-y-0">
|
||||
{/* Editable name row */}
|
||||
<div className="flex items-start justify-between gap-4 py-2 border-b border-gray-100 dark:border-slate-700/50">
|
||||
<span className="text-xs text-gray-500 dark:text-slate-400 shrink-0 pt-0.5">Name</span>
|
||||
<div className="flex-1 flex items-center justify-end gap-1 min-w-0">
|
||||
{editingName ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
className="flex-1 min-w-0 rounded border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs text-gray-800 dark:text-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={client.hostname || 'e.g. Office Printer'}
|
||||
value={nameValue}
|
||||
onChange={e => setNameValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') nameMutation.mutate(); if (e.key === 'Escape') setEditingName(false); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => nameMutation.mutate()}
|
||||
disabled={nameMutation.isPending}
|
||||
className="p-1 rounded text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30"
|
||||
title="Save"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingName(false); setNameValue(client.custom_name || ''); }}
|
||||
className="p-1 rounded text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs text-gray-800 dark:text-slate-200 truncate">
|
||||
{client.custom_name || client.hostname || (
|
||||
<span className="italic text-gray-400 dark:text-slate-500">No name</span>
|
||||
)}
|
||||
{client.custom_name && (
|
||||
<span className="ml-1.5 text-[10px] text-blue-500">custom</span>
|
||||
)}
|
||||
</span>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => setEditingName(true)}
|
||||
className="p-1 rounded text-gray-300 dark:text-slate-600 hover:text-gray-500 dark:hover:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 flex-shrink-0"
|
||||
title="Edit name"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{nameMutation.isError && (
|
||||
<p className="text-[11px] text-red-500 pb-1">Failed to save name.</p>
|
||||
)}
|
||||
|
||||
<DetailRow label="Status">
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[11px] font-medium',
|
||||
client.active
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
client.active ? 'bg-green-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{client.active ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="IP Address">
|
||||
<span className="font-mono">{client.ip_address || '—'}</span>
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="MAC Address">
|
||||
<span className="font-mono">{client.mac_address}</span>
|
||||
</DetailRow>
|
||||
|
||||
{client.vendor && (
|
||||
<DetailRow label="Vendor">{client.vendor}</DetailRow>
|
||||
)}
|
||||
|
||||
<DetailRow label="Type">
|
||||
<span className={clsx(
|
||||
'capitalize',
|
||||
client.client_type === 'wireless' ? 'text-purple-600 dark:text-purple-400' : 'text-blue-600 dark:text-blue-400'
|
||||
)}>
|
||||
{client.client_type || '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
|
||||
{client.signal_strength != null && (
|
||||
<DetailRow label="Signal">
|
||||
<span className={clsx('font-medium', signalColor(client.signal_strength))}>
|
||||
{client.signal_strength} dBm
|
||||
<span className="ml-1 font-normal opacity-75">({signalLabel(client.signal_strength)})</span>
|
||||
</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{client.vlan_id != null && (
|
||||
<DetailRow label="VLAN">
|
||||
<span className="font-mono">
|
||||
{client.vlan_id}
|
||||
{client.vlan_name && (
|
||||
<span className="ml-1.5 font-sans text-gray-500 dark:text-slate-400">({client.vlan_name})</span>
|
||||
)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
<DetailRow label="Interface">
|
||||
<span className="font-mono">{client.interface_name || '—'}</span>
|
||||
</DetailRow>
|
||||
|
||||
{client.ssid && (
|
||||
<DetailRow label="SSID">{client.ssid}</DetailRow>
|
||||
)}
|
||||
|
||||
<DetailRow label="Last Seen">
|
||||
{client.last_seen
|
||||
? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true })
|
||||
: '—'}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
{/* Wake-on-LAN */}
|
||||
{canWrite && (
|
||||
<div className="pt-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-slate-300">Wake on LAN</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWol}
|
||||
disabled={wolStatus === 'sending'}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||
wolStatus === 'sent'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: wolStatus === 'error'
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/50'
|
||||
)}
|
||||
>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
{wolStatus === 'sending' ? 'Sending…' : wolStatus === 'sent' ? 'Packet sent!' : wolStatus === 'error' ? 'Failed to send' : 'Send magic packet'}
|
||||
</button>
|
||||
<p className="text-[10px] text-gray-400 dark:text-slate-500 mt-1">
|
||||
Sends a UDP broadcast magic packet to wake this client.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="pt-1">
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-slate-300 block mb-1.5">Notes</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
readOnly={!canWrite}
|
||||
className={clsx(
|
||||
'w-full rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs text-gray-800 dark:text-slate-200 px-3 py-2 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-gray-300 dark:placeholder-slate-600',
|
||||
!canWrite && 'opacity-60 cursor-not-allowed'
|
||||
)}
|
||||
placeholder={canWrite ? 'Add notes about this client…' : 'No notes'}
|
||||
value={notes}
|
||||
onChange={e => { if (canWrite) { setNotes(e.target.value); setNotesChanged(true); } }}
|
||||
/>
|
||||
{canWrite && notesChanged && (
|
||||
<button
|
||||
onClick={() => notesMutation.mutate()}
|
||||
disabled={notesMutation.isPending}
|
||||
className="mt-1.5 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{notesMutation.isPending ? 'Saving…' : 'Save notes'}
|
||||
</button>
|
||||
)}
|
||||
{notesMutation.isError && (
|
||||
<p className="mt-1 text-[11px] text-red-500">Failed to save notes.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Traffic Graph ────────────────────────────────────────────────────────────
|
||||
|
||||
function TrafficCard({ mac }: { mac: string }) {
|
||||
const [range, setRange] = useState<'2h' | '24h' | '7d'>('24h');
|
||||
|
||||
const { data: points = [], isLoading } = useQuery({
|
||||
queryKey: ['client-traffic', mac, range],
|
||||
queryFn: () => clientsApi.getTraffic(mac, range).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const ranges = [
|
||||
{ value: '2h', label: '2h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '7d', label: '7d' },
|
||||
] as const;
|
||||
|
||||
const chartData = points.map(p => ({
|
||||
time: p.time,
|
||||
Upload: p.tx_bytes,
|
||||
Download: p.rx_bytes,
|
||||
label: format(parseISO(p.time), range === '7d' ? 'MMM d HH:mm' : 'HH:mm'),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
Wireless Usage
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setRange(r.value)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-48 bg-gray-100 dark:bg-slate-700/40 rounded animate-pulse" />
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-48 flex flex-col items-center justify-center gap-2 text-gray-400 dark:text-slate-500 text-xs bg-gray-50 dark:bg-slate-800/50 rounded">
|
||||
<Activity className="w-6 h-6 opacity-30" />
|
||||
No traffic data yet for this range
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytes(v)}
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [formatBytes(value), '']}
|
||||
contentStyle={{
|
||||
fontSize: '11px',
|
||||
backgroundColor: 'var(--tooltip-bg, #1e293b)',
|
||||
borderColor: 'var(--tooltip-border, #334155)',
|
||||
color: 'var(--tooltip-color, #e2e8f0)',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Upload"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f620"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Download"
|
||||
stroke="#8b5cf6"
|
||||
fill="#8b5cf620"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Signal Strength Graph ────────────────────────────────────────────────────
|
||||
|
||||
function qualityColor(dbm: number): string {
|
||||
if (dbm >= -55) return '#22c55e'; // green-500
|
||||
if (dbm >= -65) return '#84cc16'; // lime-500
|
||||
if (dbm >= -75) return '#eab308'; // yellow-500
|
||||
return '#ef4444'; // red-500
|
||||
}
|
||||
|
||||
function SignalCard({ mac }: { mac: string }) {
|
||||
const [range, setRange] = useState<'2h' | '24h' | '7d'>('24h');
|
||||
|
||||
const { data: raw = [], isLoading } = useQuery({
|
||||
queryKey: ['client-signal', mac, range],
|
||||
queryFn: () => clientsApi.getSignal(mac, range).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const ranges = [
|
||||
{ value: '2h', label: '2h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '7d', label: '7d' },
|
||||
] as const;
|
||||
|
||||
// Bars must grow upward from 0, but dBm values are all negative.
|
||||
// Offset each value so 0 = worst possible signal (-100 dBm), higher = stronger.
|
||||
// Y-axis tickFormatter converts back to real dBm for display.
|
||||
const BASELINE = -100;
|
||||
const chartData = raw.map((p: SignalPoint) => ({
|
||||
...p,
|
||||
label: format(parseISO(p.time), range === '7d' ? 'MMM d HH:mm' : 'HH:mm'),
|
||||
bar_value: p.signal_strength - BASELINE, // always positive
|
||||
}));
|
||||
|
||||
// Reference line Y values are also offset
|
||||
const refExcellent = -55 - BASELINE; // 45
|
||||
const refGood = -65 - BASELINE; // 35
|
||||
const refFair = -75 - BASELINE; // 25
|
||||
|
||||
// Domain: 0 to just above the highest reading
|
||||
const values = chartData.map(p => p.bar_value);
|
||||
const yDomainMax = values.length ? Math.min(BASELINE * -1, Math.max(...values) + 3) : 80;
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
Signal Strength
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setRange(r.value)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-48 bg-gray-100 dark:bg-slate-700/40 rounded animate-pulse" />
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-48 flex flex-col items-center justify-center gap-2 text-gray-400 dark:text-slate-500 text-xs bg-gray-50 dark:bg-slate-800/50 rounded">
|
||||
<Activity className="w-6 h-6 opacity-30" />
|
||||
No signal data yet for this range
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} className="opacity-20" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, yDomainMax]}
|
||||
tickFormatter={(v) => `${v + BASELINE}`}
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
width={46}
|
||||
unit=" dBm"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(_v: number, _n: string, props: { payload?: { signal_strength: number } }) =>
|
||||
[`${props.payload?.signal_strength ?? ''} dBm`, 'Signal']
|
||||
}
|
||||
contentStyle={{
|
||||
fontSize: '11px',
|
||||
backgroundColor: 'var(--tooltip-bg, #1e293b)',
|
||||
borderColor: 'var(--tooltip-border, #334155)',
|
||||
color: 'var(--tooltip-color, #e2e8f0)',
|
||||
}}
|
||||
/>
|
||||
{/* Quality threshold reference lines (offset to bar scale) */}
|
||||
<ReferenceLine y={refExcellent} stroke="#22c55e" strokeDasharray="3 3" strokeOpacity={0.6}
|
||||
label={{ value: 'Excellent', position: 'insideTopRight', fontSize: 9, fill: '#22c55e' }} />
|
||||
<ReferenceLine y={refGood} stroke="#84cc16" strokeDasharray="3 3" strokeOpacity={0.6}
|
||||
label={{ value: 'Good', position: 'insideTopRight', fontSize: 9, fill: '#84cc16' }} />
|
||||
<ReferenceLine y={refFair} stroke="#eab308" strokeDasharray="3 3" strokeOpacity={0.6}
|
||||
label={{ value: 'Fair', position: 'insideTopRight', fontSize: 9, fill: '#eab308' }} />
|
||||
<Bar dataKey="bar_value" name="Signal" radius={[2, 2, 0, 0]}>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={qualityColor(entry.signal_strength)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex items-center gap-4 text-[10px] text-gray-500 dark:text-slate-400">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-green-500 inline-block" />≥ −55 dBm Excellent</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-lime-500 inline-block" />≥ −65 Good</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-yellow-500 inline-block" />≥ −75 Fair</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-red-500 inline-block" />Poor</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ClientDetailPage() {
|
||||
const { mac } = useParams<{ mac: string }>();
|
||||
const navigate = useNavigate();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: client, isLoading, isError } = useQuery({
|
||||
queryKey: ['client-detail', mac],
|
||||
queryFn: () => clientsApi.get(mac!).then(r => r.data),
|
||||
enabled: !!mac,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-gray-400">
|
||||
Loading client…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !client) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-3 text-gray-400">
|
||||
<AlertCircle className="w-8 h-8" />
|
||||
<p>Client not found.</p>
|
||||
<button onClick={() => navigate(-1)} className="btn-secondary text-sm">Go back</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = client.custom_name || client.hostname || client.mac_address;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb / back */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-slate-400">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1 hover:text-gray-700 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<span>/</span>
|
||||
<Link to="/clients" className="hover:text-gray-700 dark:hover:text-slate-200 transition-colors">
|
||||
Clients
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-700 dark:text-slate-200 font-medium truncate">{displayName}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0',
|
||||
client.active
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-gray-100 dark:bg-slate-700'
|
||||
)}>
|
||||
{client.client_type === 'wireless'
|
||||
? <Wifi className={clsx('w-5 h-5', client.active ? 'text-green-600 dark:text-green-400' : 'text-gray-400')} />
|
||||
: <Network className={clsx('w-5 h-5', client.active ? 'text-green-600 dark:text-green-400' : 'text-gray-400')} />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{displayName}</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 font-mono">{client.mac_address}</p>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'ml-auto px-2.5 py-1 rounded-full text-xs font-semibold',
|
||||
client.active
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
)}>
|
||||
{client.active ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connectivity timeline */}
|
||||
<ConnectivityTimeline mac={mac!} />
|
||||
|
||||
{/* Connection diagram */}
|
||||
<ConnectionDiagram client={client} />
|
||||
|
||||
{/* Details on left; Traffic + Signal stacked on right */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
|
||||
<ClientDetailsCard client={client} canWrite={canWrite} />
|
||||
{client.client_type === 'wireless' && (
|
||||
<div className="space-y-4">
|
||||
<TrafficCard mac={mac!} />
|
||||
<SignalCard mac={mac!} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
import { clientsApi } from '../services/api';
|
||||
import type { Client } from '../types';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
|
||||
|
||||
function ClientModal({
|
||||
client,
|
||||
onClose,
|
||||
}: {
|
||||
client: Client;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(client.custom_name || '');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => clientsApi.updateHostname(client.mac_address, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Edit Client</h2>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm text-gray-500 dark:text-slate-400 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span>MAC</span>
|
||||
<span className="font-mono text-gray-800 dark:text-white">{client.mac_address}</span>
|
||||
</div>
|
||||
{client.vendor && (
|
||||
<div className="flex justify-between">
|
||||
<span>Vendor</span>
|
||||
<span className="text-gray-800 dark:text-white">{client.vendor}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.ip_address && (
|
||||
<div className="flex justify-between">
|
||||
<span>IP</span>
|
||||
<span className="font-mono text-gray-800 dark:text-white">{client.ip_address}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.interface_name && (
|
||||
<div className="flex justify-between">
|
||||
<span>Port</span>
|
||||
<span className="font-mono text-gray-800 dark:text-white">{client.interface_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.hostname && (
|
||||
<div className="flex justify-between">
|
||||
<span>Discovered Hostname</span>
|
||||
<span className="font-mono text-gray-800 dark:text-white">{client.hostname}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">
|
||||
Custom Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full mb-1"
|
||||
placeholder={client.hostname || 'e.g. Office Printer'}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') mutation.mutate(); }}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mb-4">
|
||||
Overrides the auto-discovered hostname. Persists even when the client is offline.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||||
<button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{mutation.isError && (
|
||||
<p className="mt-2 text-xs text-red-500">Failed to save name.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const canWrite = useCanWrite();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||
const [sortCol, setSortCol] = useState<string>('last_seen');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const toggleSort = (col: string) => {
|
||||
if (sortCol === col) setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortCol(col); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['clients', { search, showAll, page }],
|
||||
queryFn: () =>
|
||||
clientsApi
|
||||
.list({ search: search || undefined, active: showAll ? undefined : true, limit: PAGE_SIZE, offset: page * PAGE_SIZE })
|
||||
.then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const [purgeResult, setPurgeResult] = useState('');
|
||||
const purgeMutation = useMutation({
|
||||
mutationFn: () => clientsApi.purgeStale(),
|
||||
onSuccess: (r) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
setPurgeResult(r.data.message);
|
||||
setTimeout(() => setPurgeResult(''), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
useSocket({
|
||||
'clients:updated': () => queryClient.invalidateQueries({ queryKey: ['clients'] }),
|
||||
});
|
||||
|
||||
const rawClients = data?.clients ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const clients = useMemo(() => {
|
||||
const toIpNum = (ip: string) =>
|
||||
(ip || '').split('.').reduce((n, o) => n * 256 + parseInt(o || '0', 10), 0);
|
||||
|
||||
return [...rawClients].sort((a, b) => {
|
||||
let av: string | number, bv: string | number;
|
||||
switch (sortCol) {
|
||||
case 'hostname': av = a.custom_name || a.hostname || ''; bv = b.custom_name || b.hostname || ''; break;
|
||||
case 'vendor': av = a.vendor || ''; bv = b.vendor || ''; break;
|
||||
case 'ip_address': av = toIpNum(a.ip_address || ''); bv = toIpNum(b.ip_address || ''); break;
|
||||
case 'client_type': av = a.client_type || ''; bv = b.client_type || ''; break;
|
||||
case 'interface_name': av = a.interface_name || ''; bv = b.interface_name || ''; break;
|
||||
case 'vlan_id': av = a.vlan_id ?? -1; bv = b.vlan_id ?? -1; break;
|
||||
case 'device_name': av = a.device_name || ''; bv = b.device_name || ''; break;
|
||||
case 'last_seen': av = a.last_seen || ''; bv = b.last_seen || ''; break;
|
||||
default: av = ''; bv = '';
|
||||
}
|
||||
if (av === bv) return 0;
|
||||
const cmp = av < bv ? -1 : 1;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [rawClients, sortCol, sortDir]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{editingClient && (
|
||||
<ClientModal client={editingClient} onClose={() => setEditingClient(null)} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Clients
|
||||
{total > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||||
({total} {showAll ? 'total' : 'online'})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<div className="relative w-full sm:flex-1 sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Search hostname, MAC, IP, or vendor…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowAll((s) => !s); setPage(0); }}
|
||||
className={clsx(
|
||||
'px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
showAll
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-700'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{showAll ? 'Active Only' : 'Show Inactive'}
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Purge all inactive clients older than the configured retention period?')) {
|
||||
purgeMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={purgeMutation.isPending}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm"
|
||||
title="Delete inactive client records older than the retention period (set in Settings)"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Purge Stale
|
||||
</button>
|
||||
)}
|
||||
{purgeResult && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">{purgeResult}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
) : clients.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-3 text-center">
|
||||
<Users className="w-12 h-12 text-gray-300 dark:text-slate-600" />
|
||||
<p className="text-gray-500 dark:text-slate-400">
|
||||
{showAll ? 'No clients found' : 'No active clients detected'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
{([
|
||||
{ col: 'hostname', label: 'Host / Vendor / MAC', align: 'left' },
|
||||
{ col: 'ip_address', label: 'IP Address', align: 'left' },
|
||||
{ col: 'client_type', label: 'Type', align: 'left' },
|
||||
{ col: 'interface_name', label: 'Port', align: 'left' },
|
||||
{ col: 'vlan_id', label: 'VLAN', align: 'left' },
|
||||
{ col: 'device_name', label: 'Device', align: 'left' },
|
||||
{ col: 'last_seen', label: 'Last Seen', align: 'left' },
|
||||
] as { col: string; label: string; align: 'left' | 'right' }[]).map(({ col, label, align }) => (
|
||||
<th
|
||||
key={col}
|
||||
className={clsx('table-header px-4 py-2.5 cursor-pointer select-none whitespace-nowrap', `text-${align}`)}
|
||||
onClick={() => toggleSort(col)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
{sortCol === col ? (
|
||||
sortDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3 text-blue-500" />
|
||||
: <ChevronDown className="w-3 h-3 text-blue-500" />
|
||||
) : (
|
||||
<ChevronsUpDown className="w-3 h-3 text-gray-300 dark:text-slate-600" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
{showAll && (
|
||||
<th className="table-header px-4 py-2.5 text-center">Active</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{clients.map((client) => (
|
||||
<tr
|
||||
key={client.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-slate-700/30"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)}
|
||||
className="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left"
|
||||
>
|
||||
{client.custom_name || client.hostname || (
|
||||
<span className="text-gray-400 dark:text-slate-500 italic">No hostname</span>
|
||||
)}
|
||||
</button>
|
||||
{client.custom_name && (
|
||||
<span className="text-xs text-blue-500 dark:text-blue-400 ml-1">custom</span>
|
||||
)}
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setEditingClient(client); }}
|
||||
className="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700"
|
||||
title="Edit name"
|
||||
>
|
||||
<Pencil className="w-3 h-3 text-gray-300 dark:text-slate-600 hover:text-gray-500 dark:hover:text-slate-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{client.vendor && (
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">{client.vendor}</div>
|
||||
)}
|
||||
<div className="text-xs font-mono text-gray-400 dark:text-slate-500">
|
||||
{client.mac_address}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400">
|
||||
{client.ip_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 text-xs font-medium',
|
||||
client.client_type === 'wireless'
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
{client.client_type === 'wireless' ? (
|
||||
<Wifi className="w-3 h-3" />
|
||||
) : (
|
||||
<Network className="w-3 h-3" />
|
||||
)}
|
||||
{client.client_type}
|
||||
{client.signal_strength != null && (
|
||||
<span className="text-gray-400 ml-1">({client.signal_strength} dBm)</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{client.interface_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
{client.vlan_id != null
|
||||
? <span className="px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono">{client.vlan_id}</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
{client.device_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">
|
||||
{client.last_seen
|
||||
? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true })
|
||||
: '—'}
|
||||
</td>
|
||||
{showAll && (
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'w-2 h-2 rounded-full inline-block',
|
||||
client.active ? 'bg-green-500' : 'bg-gray-300 dark:bg-slate-600'
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-slate-400">
|
||||
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="btn-secondary py-1"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
className="btn-secondary py-1"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, Legend,
|
||||
} from 'recharts';
|
||||
import { Router, Users, AlertTriangle, Wifi, Activity, TrendingUp, MapPin, ArrowUpCircle, X, Signal } from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { metricsApi, eventsApi, devicesApi, clientsApi } from '../services/api';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import type { Device, DeviceEvent } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const ALL_SEVERITIES = ['error', 'warning', 'info'] as const;
|
||||
|
||||
// ─── Device Locations Map ────────────────────────────────────────────────────
|
||||
|
||||
function DeviceLocationsMap({ devices }: { devices: Device[] }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const mapped = useMemo(() => devices.filter(
|
||||
(d) => d.location_lat != null && d.location_lng != null &&
|
||||
!isNaN(Number(d.location_lat)) && !isNaN(Number(d.location_lng))
|
||||
), [devices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapped.length === 0) return;
|
||||
|
||||
const map = L.map(containerRef.current, { scrollWheelZoom: false });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
// Group devices by exact lat/lng so co-located devices share one marker
|
||||
const groups = new Map<string, Device[]>();
|
||||
for (const d of mapped) {
|
||||
const key = `${Number(d.location_lat).toFixed(6)},${Number(d.location_lng).toFixed(6)}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(d);
|
||||
}
|
||||
|
||||
const markers: L.Layer[] = [];
|
||||
|
||||
groups.forEach((groupDevices) => {
|
||||
const lat = Number(groupDevices[0].location_lat);
|
||||
const lng = Number(groupDevices[0].location_lng);
|
||||
const count = groupDevices.length;
|
||||
|
||||
// Worst-status color: offline > unknown > online
|
||||
const hasOffline = groupDevices.some((d) => d.status === 'offline');
|
||||
const hasUnknown = groupDevices.some((d) => d.status !== 'online' && d.status !== 'offline');
|
||||
const color = hasOffline ? '#ef4444' : hasUnknown ? '#94a3b8' : '#10b981';
|
||||
|
||||
// Popup: list every device with its status
|
||||
const address = groupDevices[0].location_address
|
||||
? `<br/><span style="color:#64748b;font-size:11px">${groupDevices[0].location_address}</span>`
|
||||
: '';
|
||||
const deviceRows = groupDevices
|
||||
.map((d) => {
|
||||
const c = d.status === 'online' ? '#10b981' : d.status === 'offline' ? '#ef4444' : '#94a3b8';
|
||||
return `<b>${d.name}</b> <span style="color:${c};font-size:11px;font-weight:600">● ${d.status}</span>`;
|
||||
})
|
||||
.join('<br/>');
|
||||
const popupHtml = `${deviceRows}${address}`;
|
||||
|
||||
// Tooltip on hover: quick device list with emoji indicators
|
||||
const tooltipHtml = groupDevices
|
||||
.map((d) => {
|
||||
const dot = d.status === 'online' ? '🟢' : d.status === 'offline' ? '🔴' : '⚪';
|
||||
return `${dot} ${d.name}`;
|
||||
})
|
||||
.join('<br/>');
|
||||
|
||||
let marker: L.Layer;
|
||||
if (count === 1) {
|
||||
marker = L.circleMarker([lat, lng], {
|
||||
radius: 9,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.9,
|
||||
})
|
||||
.bindPopup(popupHtml)
|
||||
.bindTooltip(tooltipHtml, { direction: 'top', offset: [0, -10] });
|
||||
} else {
|
||||
const size = count > 9 ? 32 : 28;
|
||||
const half = size / 2;
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="background:${color};color:#fff;border:2px solid #fff;border-radius:50%;width:${size}px;height:${size}px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;box-shadow:0 1px 4px rgba(0,0,0,0.35);cursor:pointer">${count}</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [half, half],
|
||||
popupAnchor: [0, -half],
|
||||
tooltipAnchor: [0, -half],
|
||||
});
|
||||
marker = L.marker([lat, lng], { icon })
|
||||
.bindPopup(popupHtml)
|
||||
.bindTooltip(tooltipHtml, { direction: 'top' });
|
||||
}
|
||||
|
||||
marker.addTo(map);
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
if (markers.length === 1) {
|
||||
const first = mapped[0];
|
||||
map.setView([Number(first.location_lat), Number(first.location_lng)], 13);
|
||||
} else {
|
||||
map.fitBounds(L.featureGroup(markers).getBounds(), { padding: [40, 40] });
|
||||
}
|
||||
|
||||
return () => { map.remove(); };
|
||||
}, [mapped]);
|
||||
|
||||
if (mapped.length === 0) {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm text-center px-4">
|
||||
No device locations configured — add addresses via Device › Overview › Physical Details
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div ref={containerRef} style={{ height: 300, width: '100%', borderRadius: 8 }} />;
|
||||
}
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
color = 'blue',
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const colors: Record<string, string> = {
|
||||
blue: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
green: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
|
||||
red: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
yellow: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('card p-5', onClick && 'cursor-pointer hover:shadow-md transition-shadow')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={clsx('p-3 rounded-xl', colors[color])}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{value}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-slate-400">{label}</div>
|
||||
{sub && <div className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const classes: Record<string, string> = {
|
||||
error: 'severity-error',
|
||||
critical: 'severity-error',
|
||||
warning: 'severity-warning',
|
||||
info: 'severity-info',
|
||||
};
|
||||
return (
|
||||
<span className={clsx('px-2 py-0.5 rounded text-xs font-medium', classes[severity] || classes.info)}>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const SEVERITY_KEY = 'dashboard:severities';
|
||||
|
||||
function loadSeverities(): Set<string> {
|
||||
try {
|
||||
const saved = localStorage.getItem(SEVERITY_KEY);
|
||||
if (saved) return new Set(JSON.parse(saved));
|
||||
} catch { /* ignore */ }
|
||||
return new Set(ALL_SEVERITIES);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [severities, setSeverities] = useState<Set<string>>(loadSeverities);
|
||||
|
||||
const toggleSeverity = (s: string) =>
|
||||
setSeverities((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s)) next.delete(s); else next.add(s);
|
||||
localStorage.setItem(SEVERITY_KEY, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
|
||||
const severityParam =
|
||||
severities.size === 0 || severities.size === ALL_SEVERITIES.length
|
||||
? undefined
|
||||
: [...severities].join(',');
|
||||
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ['metrics-summary'],
|
||||
queryFn: () => metricsApi.summary().then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: clientsOverTimeRaw = [] } = useQuery({
|
||||
queryKey: ['clients-over-time', '24h'],
|
||||
queryFn: () => metricsApi.clientsOverTime('24h').then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
// Convert ISO timestamps to numeric ms for a proper continuous time axis
|
||||
const clientsOverTime = clientsOverTimeRaw.map((p) => ({
|
||||
...p,
|
||||
ts: new Date(p.time).getTime(),
|
||||
}));
|
||||
|
||||
const { data: topClients = [] } = useQuery({
|
||||
queryKey: ['top-clients'],
|
||||
queryFn: () => metricsApi.topClients(8).then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: wirelessClientsData } = useQuery({
|
||||
queryKey: ['wireless-clients-active'],
|
||||
queryFn: () => clientsApi.list({ active: true, client_type: 'wireless', limit: 1 }).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: eventsData } = useQuery({
|
||||
queryKey: ['events-recent', severityParam],
|
||||
queryFn: () => eventsApi.list({ limit: 5, severity: severityParam }).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
useSocket({
|
||||
'device:updated': () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['metrics-summary'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||
},
|
||||
'clients:updated': () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['top-clients'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['metrics-summary'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['wireless-clients-active'] });
|
||||
},
|
||||
'events:updated': () => queryClient.invalidateQueries({ queryKey: ['events-recent'] }),
|
||||
'device:status': () => queryClient.invalidateQueries({ queryKey: ['metrics-summary'] }),
|
||||
});
|
||||
|
||||
const [dismissedFirmwareIds, setDismissedFirmwareIds] = useState<number[]>([]);
|
||||
const devicesWithUpdates = devices.filter(
|
||||
(d) => d.firmware_update_available && !dismissedFirmwareIds.includes(d.id)
|
||||
);
|
||||
|
||||
// Device type distribution for pie chart
|
||||
const deviceTypeLabel: Record<string, string> = {
|
||||
router: 'Router', switch: 'Switch', wireless_ap: 'Wireless AP', other: 'Other',
|
||||
};
|
||||
const deviceTypes = devices.reduce((acc: Record<string, number>, d) => {
|
||||
const type = d.device_type || 'other';
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const pieData = Object.entries(deviceTypes).map(([key, value]) => ({ name: deviceTypeLabel[key] ?? key, value }));
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||
|
||||
{/* Firmware update banner */}
|
||||
{devicesWithUpdates.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-700/50 dark:bg-amber-900/20 px-4 py-3 flex items-start gap-3">
|
||||
<ArrowUpCircle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
<span className="font-semibold">Firmware update{devicesWithUpdates.length > 1 ? 's' : ''} available</span>
|
||||
{' — '}
|
||||
{devicesWithUpdates.map((d, i) => (
|
||||
<span key={d.id}>
|
||||
{i > 0 && ', '}
|
||||
<button
|
||||
onClick={() => navigate(`/devices/${d.id}`)}
|
||||
className="underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200 font-medium"
|
||||
>
|
||||
{d.name}
|
||||
{d.latest_ros_version && (
|
||||
<span className="font-normal"> ({d.latest_ros_version})</span>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissedFirmwareIds((ids) => [...ids, ...devicesWithUpdates.map((d) => d.id)])}
|
||||
className="p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800/50 text-amber-500 flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
icon={Router}
|
||||
label="Total Devices"
|
||||
value={summary?.devices.total ?? '—'}
|
||||
sub={`${summary?.devices.online ?? 0} online, ${summary?.devices.offline ?? 0} offline`}
|
||||
color="blue"
|
||||
onClick={() => navigate('/devices')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Wifi}
|
||||
label="Active Clients"
|
||||
value={summary?.clients.active ?? '—'}
|
||||
sub={`${summary?.clients.total ?? 0} total known`}
|
||||
color="green"
|
||||
onClick={() => navigate('/clients')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Signal}
|
||||
label="Wireless Clients"
|
||||
value={wirelessClientsData?.total ?? '—'}
|
||||
color="blue"
|
||||
onClick={() => navigate('/wireless/clients')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Alerts (24h)"
|
||||
value={summary?.alerts.critical ?? '—'}
|
||||
sub={`${summary?.alerts.warning ?? 0} warnings`}
|
||||
color={summary?.alerts.critical ? 'red' : 'yellow'}
|
||||
onClick={() => navigate('/events')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Devices Online"
|
||||
value={summary?.devices.online ?? '—'}
|
||||
sub={`of ${summary?.devices.total ?? 0} total`}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Devices Offline"
|
||||
value={summary?.devices.offline ?? '—'}
|
||||
sub={`of ${summary?.devices.total ?? 0} total`}
|
||||
color={summary?.devices.offline ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Clients over time */}
|
||||
<div className="card p-5 lg:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Connected Clients (24h)
|
||||
</h2>
|
||||
</div>
|
||||
{clientsOverTime.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={clientsOverTime} margin={{ top: 5, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="ts"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={[Date.now() - 24 * 60 * 60 * 1000, Date.now()]}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickCount={7}
|
||||
tickFormatter={(t) => format(new Date(t), 'HH:mm')}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
formatter={(v: number) => [v, 'Clients']}
|
||||
labelFormatter={(t) => format(new Date(t as number), 'MMM d, HH:mm')}
|
||||
contentStyle={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[220px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No data yet — metrics will appear after devices are polled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device type pie */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Router className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Device Types</h2>
|
||||
</div>
|
||||
{pieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((_entry, index) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend formatter={(v) => <span className="text-xs capitalize">{v}</span>} />
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[220px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No devices added yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device locations map */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Device Locations</h2>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{devices.filter((d) => d.location_lat != null && d.location_lng != null).length} of {devices.length} devices mapped
|
||||
</span>
|
||||
</div>
|
||||
<DeviceLocationsMap devices={devices} />
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Top clients + Recent events */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top clients */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Top Clients</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/clients')}
|
||||
className="text-xs text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
{topClients.length > 0 ? (() => {
|
||||
const top5 = topClients.slice(0, 5);
|
||||
const maxBytes = top5[0]?.total_bytes ?? 1;
|
||||
const barColors = ['#3b82f6', '#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd'];
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{top5.map((client, i) => {
|
||||
const pct = Math.max(4, (client.total_bytes / maxBytes) * 100);
|
||||
const label = client.hostname || client.mac_address || 'Unknown';
|
||||
return (
|
||||
<div
|
||||
key={client.mac_address}
|
||||
className="flex items-center gap-2 cursor-pointer group"
|
||||
onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)}
|
||||
>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500 w-3 flex-shrink-0 text-right">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-slate-300 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||
{label.length > 22 ? label.slice(0, 22) + '…' : label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-slate-400 flex-shrink-0 font-mono">
|
||||
{formatBytes(client.total_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: barColors[i] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<div className="h-[120px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No active clients
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent events */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-blue-500" />
|
||||
<button
|
||||
onClick={() => navigate('/events')}
|
||||
className="text-sm font-semibold text-gray-900 dark:text-white hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Recent Events
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
{([
|
||||
{ key: 'error', label: 'Error', color: 'text-red-500' },
|
||||
{ key: 'warning', label: 'Warning', color: 'text-yellow-500' },
|
||||
{ key: 'info', label: 'Info', color: 'text-blue-500' },
|
||||
] as const).map(({ key, label, color }) => (
|
||||
<label key={key} className="flex items-center gap-1 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={severities.has(key)}
|
||||
onChange={() => toggleSeverity(key)}
|
||||
className="w-3 h-3 rounded cursor-pointer"
|
||||
/>
|
||||
<span className={clsx('text-xs font-medium', color)}>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
onClick={() => navigate('/events')}
|
||||
className="text-xs text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{(eventsData?.events ?? []).length > 0 ? (
|
||||
eventsData!.events.map((ev: DeviceEvent) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-start gap-3 py-1.5 border-b border-gray-100 dark:border-slate-700 last:border-0"
|
||||
>
|
||||
<SeverityBadge severity={ev.severity} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-700 dark:text-slate-300 truncate">
|
||||
{ev.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">
|
||||
{ev.device_name} · {format(new Date(ev.event_time), 'MMM d, HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[120px] text-gray-400 dark:text-slate-500 text-sm">
|
||||
No recent events
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, RefreshCw, Activity, Cpu, MemoryStick, Clock, ExternalLink, TerminalSquare,
|
||||
} from 'lucide-react';
|
||||
import { devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import SwitchPortDiagram from '../components/ports/SwitchPortDiagram';
|
||||
import TerminalModal from '../components/TerminalModal';
|
||||
import DeviceLocationSection from '../components/device-detail/DeviceLocationSection';
|
||||
import VlansTab from '../components/device-detail/VlansTab';
|
||||
import RoutingTab from '../components/device-detail/RoutingTab';
|
||||
import FirewallTab from '../components/device-detail/FirewallTab';
|
||||
import SystemConfigTab from '../components/device-detail/SystemConfigTab';
|
||||
import HardwareTab from '../components/device-detail/HardwareTab';
|
||||
import ToolsTab from '../components/device-detail/ToolsTab';
|
||||
import RadiosTab from '../components/device-detail/RadiosTab';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'config' | 'hardware' | 'tools' | 'radios';
|
||||
|
||||
function formatUptime(raw: string): string {
|
||||
if (!raw) return '—';
|
||||
const weeks = parseInt(raw.match(/(\d+)w/)?.[1] ?? '0', 10);
|
||||
const days = parseInt(raw.match(/(\d+)d/)?.[1] ?? '0', 10);
|
||||
const hours = parseInt(raw.match(/(\d+)h/)?.[1] ?? '0', 10);
|
||||
const minutes = parseInt(raw.match(/(\d+)m/)?.[1] ?? '0', 10);
|
||||
const seconds = parseInt(raw.match(/(\d+)s/)?.[1] ?? '0', 10);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (weeks) parts.push(`${weeks} ${weeks === 1 ? 'Week' : 'Weeks'}`);
|
||||
if (days) parts.push(`${days} ${days === 1 ? 'Day' : 'Days'}`);
|
||||
if (hours) parts.push(`${hours} ${hours === 1 ? 'Hour' : 'Hours'}`);
|
||||
if (minutes) parts.push(`${minutes} ${minutes === 1 ? 'Minute' : 'Minutes'}`);
|
||||
|
||||
// Only show seconds when nothing larger is present (uptime < 1 minute)
|
||||
if (parts.length === 0) parts.push(`${seconds} ${seconds === 1 ? 'Second' : 'Seconds'}`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export default function DeviceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview');
|
||||
const [autoOpenBridge, setAutoOpenBridge] = useState<string | null>(null);
|
||||
const [showTerminal, setShowTerminal] = useState(false);
|
||||
|
||||
const deviceId = parseInt(id!);
|
||||
|
||||
const { data: device, isLoading } = useQuery({
|
||||
queryKey: ['device', deviceId],
|
||||
queryFn: () => devicesApi.get(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: resources } = useQuery({
|
||||
queryKey: ['device-resources-live', deviceId],
|
||||
queryFn: () => devicesApi.getResources(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
enabled: device?.status === 'online',
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => devicesApi.sync(deviceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['device', deviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interfaces', deviceId] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3">
|
||||
<p className="text-gray-500">Device not found</p>
|
||||
<button onClick={() => navigate('/devices')} className="btn-secondary">
|
||||
Back to Devices
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWirelessAP = device.device_type === 'wireless_ap';
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'ports', label: 'Ports' },
|
||||
{ key: 'vlans', label: 'VLANs' },
|
||||
{ key: 'routing', label: 'Routing' },
|
||||
{ key: 'firewall', label: 'Firewall' },
|
||||
{ key: 'config', label: 'Config' },
|
||||
{ key: 'hardware', label: 'Hardware' },
|
||||
...(isWirelessAP ? [{ key: 'radios' as TabKey, label: 'Radios' }] : []),
|
||||
{ key: 'tools', label: 'Tools' },
|
||||
];
|
||||
|
||||
const cpuLoad = parseInt(resources?.['cpu-load'] || '0', 10);
|
||||
const memTotal = parseInt(resources?.['total-memory'] || '0', 10);
|
||||
const memFree = parseInt(resources?.['free-memory'] || '0', 10);
|
||||
const memUsed = memTotal - memFree;
|
||||
const memPercent = memTotal > 0 ? Math.round((memUsed / memTotal) * 100) : 0;
|
||||
const formatMB = (bytes: number) => `${Math.round(bytes / 1024 / 1024)} MB`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/devices')}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{device.name}</h1>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0',
|
||||
device.status === 'online' && 'status-online',
|
||||
device.status === 'offline' && 'status-offline',
|
||||
device.status === 'unknown' && 'status-unknown'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
device.status === 'online' && 'bg-green-500 animate-pulse',
|
||||
device.status === 'offline' && 'bg-red-500',
|
||||
device.status === 'unknown' && 'bg-gray-400'
|
||||
)}
|
||||
/>
|
||||
{device.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 font-mono truncate">
|
||||
{device.ip_address}:{device.api_port}
|
||||
{device.model && ` · ${device.model}`}
|
||||
{device.ros_version && ` · ROS ${device.ros_version}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap sm:ml-auto sm:flex-shrink-0">
|
||||
<button
|
||||
onClick={() => window.open(`http://${device.ip_address}/`, '_blank', 'noopener,noreferrer')}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
title="Open device web interface"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Web Admin</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTerminal(true)}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
title="Open SSH terminal"
|
||||
>
|
||||
<TerminalSquare className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Terminal</span>
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', syncMutation.isPending && 'animate-spin')} />
|
||||
<span className="hidden sm:inline">Sync</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200 dark:border-slate-700 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={clsx(
|
||||
'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px whitespace-nowrap',
|
||||
activeTab === tab.key
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
{/* Resource stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">CPU Load</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{resources ? `${cpuLoad}%` : '—'}
|
||||
</div>
|
||||
{resources && (
|
||||
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all',
|
||||
cpuLoad > 80 ? 'bg-red-500' : cpuLoad > 50 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
)}
|
||||
style={{ width: `${cpuLoad}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">Memory</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{resources ? `${memPercent}%` : '—'}
|
||||
</div>
|
||||
{resources && (
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||
{formatMB(memUsed)} / {formatMB(memTotal)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">Uptime</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white truncate">
|
||||
{resources ? formatUptime(resources['uptime'] || '') : '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">Version</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white">
|
||||
{device.ros_version || '—'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||
{device.firmware_version || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System details */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
|
||||
System Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
{[
|
||||
['Name', device.name],
|
||||
['IP Address', device.ip_address],
|
||||
['Model', device.model || '—'],
|
||||
['Serial Number', device.serial_number || '—'],
|
||||
['RouterOS Version', device.ros_version || '—'],
|
||||
['Firmware', device.firmware_version || '—'],
|
||||
['Type', device.device_type],
|
||||
['API Port', String(device.api_port)],
|
||||
['Added', new Date(device.created_at).toLocaleDateString()],
|
||||
['Last Seen', device.last_seen ? new Date(device.last_seen).toLocaleString() : '—'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between py-1 border-b border-gray-100 dark:border-slate-700">
|
||||
<span className="text-gray-500 dark:text-slate-400">{k}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-right">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Physical location, rack & notes */}
|
||||
<DeviceLocationSection device={device} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ports' && (
|
||||
<SwitchPortDiagram
|
||||
deviceId={deviceId}
|
||||
autoOpenBridge={autoOpenBridge ?? undefined}
|
||||
onBridgeOpened={() => setAutoOpenBridge(null)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'vlans' && (
|
||||
<VlansTab
|
||||
deviceId={deviceId}
|
||||
onGoToPorts={(bridgeName) => { setAutoOpenBridge(bridgeName); setActiveTab('ports'); }}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'routing' && <RoutingTab deviceId={deviceId} />}
|
||||
{activeTab === 'firewall' && <FirewallTab deviceId={deviceId} deviceType={device?.device_type} />}
|
||||
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} />}
|
||||
{activeTab === 'hardware' && <HardwareTab deviceId={deviceId} />}
|
||||
{activeTab === 'radios' && <RadiosTab deviceId={deviceId} deviceStatus={device.status} />}
|
||||
{activeTab === 'tools' && <ToolsTab deviceId={deviceId} />}
|
||||
|
||||
{showTerminal && (
|
||||
<TerminalModal
|
||||
deviceId={deviceId}
|
||||
deviceName={device.name}
|
||||
onClose={() => setShowTerminal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, RefreshCw, Router, Wifi, Trash2, ChevronRight, Search, Radar, ArrowUpCircle } from 'lucide-react';
|
||||
import { devicesApi, topologyApi } from '../services/api';
|
||||
import type { Device } from '../types';
|
||||
import type { DiscoveredDevice } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
import AddDeviceModal from '../components/devices/AddDeviceModal';
|
||||
|
||||
function StatusDot({ status }: { status: Device['status'] }) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' && 'status-online',
|
||||
status === 'offline' && 'status-offline',
|
||||
status === 'unknown' && 'status-unknown'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' && 'bg-green-500',
|
||||
status === 'offline' && 'bg-red-500',
|
||||
status === 'unknown' && 'bg-gray-400'
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceTypeIcon({ type }: { type: Device['device_type'] }) {
|
||||
if (type === 'wireless_ap') return <Wifi className="w-5 h-5 text-blue-500" />;
|
||||
return <Router className="w-5 h-5 text-blue-500" />;
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addPrefill, setAddPrefill] = useState<{ name?: string; ip_address?: string } | undefined>();
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const { data: devices = [], isLoading } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: discovered = [] } = useQuery({
|
||||
queryKey: ['devices-discovered'],
|
||||
queryFn: () => devicesApi.discovered().then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (id: number) => devicesApi.sync(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['devices'] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => devicesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
const filtered = devices.filter(
|
||||
(d) =>
|
||||
!search ||
|
||||
d.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
d.ip_address.includes(search) ||
|
||||
d.model?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Devices
|
||||
{devices.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||||
({devices.filter((d) => d.status === 'online').length}/{devices.length} online)
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
await topologyApi.discover();
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['devices-discovered'] });
|
||||
setSearching(false);
|
||||
}, 8000);
|
||||
} catch {
|
||||
setSearching(false);
|
||||
}
|
||||
}}
|
||||
disabled={searching}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', searching && 'animate-spin')} />
|
||||
{searching ? 'Searching…' : 'Search for Devices'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setAddPrefill(undefined); setShowAddModal(true); }}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Search devices..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Device list */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-16 h-16 bg-gray-100 dark:bg-slate-700 rounded-full flex items-center justify-center">
|
||||
<Router className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-700 dark:text-slate-300 font-medium">
|
||||
{search ? 'No devices match your search' : 'No devices added yet'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
{!search && 'Click "Add Device" to connect your first Mikrotik device'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-3">Device</th>
|
||||
<th className="table-header px-4 py-3">IP Address</th>
|
||||
<th className="table-header px-4 py-3">Model</th>
|
||||
<th className="table-header px-4 py-3">ROS Version</th>
|
||||
<th className="table-header px-4 py-3">Status</th>
|
||||
<th className="table-header px-4 py-3">Last Seen</th>
|
||||
<th className="table-header px-4 py-3 w-28">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{filtered.map((device) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/devices/${device.id}`)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<DeviceTypeIcon type={device.device_type} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{device.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{{ router: 'Router', switch: 'Switch', wireless_ap: 'Wireless AP', other: 'Other' }[device.device_type] ?? device.device_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-600 dark:text-slate-400">
|
||||
{device.ip_address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-slate-400">
|
||||
{device.model || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-slate-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{device.ros_version || '—'}
|
||||
{device.firmware_update_available && (
|
||||
<span
|
||||
title={device.latest_ros_version ? `Update available: ${device.latest_ros_version}` : 'Firmware update available'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<ArrowUpCircle className="w-3.5 h-3.5 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusDot status={device.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400 dark:text-slate-500">
|
||||
{device.last_seen
|
||||
? new Date(device.last_seen).toLocaleString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{canWrite && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => syncMutation.mutate(device.id)}
|
||||
disabled={syncMutation.isPending}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Sync now"
|
||||
>
|
||||
<RefreshCw
|
||||
className={clsx('w-3.5 h-3.5', syncMutation.isPending && 'animate-spin')}
|
||||
/>
|
||||
</button>
|
||||
{deleteConfirm === device.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(device.id)}
|
||||
className="text-xs text-red-500 hover:text-red-600 font-medium px-1"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(device.id)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Delete device"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 dark:text-slate-600" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovered (unmanaged) MikroTik neighbors */}
|
||||
{discovered.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar className="w-4 h-4 text-amber-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-300">
|
||||
Discovered Devices
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500">
|
||||
— Mikrotik neighbors seen by your managed devices, not yet added to management
|
||||
</span>
|
||||
</div>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Identity</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MAC Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Seen By</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Last Seen</th>
|
||||
<th className="table-header px-4 py-2.5 w-36" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(discovered as DiscoveredDevice[]).map((d, i) => (
|
||||
<tr key={d.mac_address || d.address || i} className="hover:bg-amber-50/50 dark:hover:bg-amber-900/10">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{d.identity || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400">
|
||||
{d.address ? (
|
||||
d.address
|
||||
) : (
|
||||
<span className="text-amber-500 dark:text-amber-400 italic text-xs font-sans">
|
||||
Not detected — enter manually
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-500">
|
||||
{d.mac_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400 text-xs">
|
||||
{d.seen_by}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 text-xs">
|
||||
{new Date(d.discovered_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddPrefill({ name: d.identity || '', ip_address: d.address });
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
className="btn-primary text-xs py-1 px-3 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add to Manager
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddModal && (
|
||||
<AddDeviceModal
|
||||
prefill={addPrefill}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowAddModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['devices-discovered'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, Search, Trash2, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
||||
import { eventsApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import { format } from 'date-fns';
|
||||
import type { EventSeverity } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const severityIcon = (severity: EventSeverity) => {
|
||||
if (severity === 'error' || severity === 'critical') return AlertCircle;
|
||||
if (severity === 'warning') return AlertTriangle;
|
||||
return Info;
|
||||
};
|
||||
|
||||
const severityColor = (severity: EventSeverity): string => {
|
||||
if (severity === 'error' || severity === 'critical') return 'text-red-500';
|
||||
if (severity === 'warning') return 'text-yellow-500';
|
||||
return 'text-blue-500';
|
||||
};
|
||||
|
||||
const ALL_SEVERITIES = ['error', 'warning', 'info'] as const;
|
||||
|
||||
export default function EventsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [search, setSearch] = useState('');
|
||||
const [severities, setSeverities] = useState<Set<string>>(new Set(ALL_SEVERITIES));
|
||||
const [topic, setTopic] = useState('');
|
||||
const [deviceId, setDeviceId] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
const toggleSeverity = (s: string) => {
|
||||
setSeverities((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s)) next.delete(s); else next.add(s);
|
||||
return next;
|
||||
});
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// No filter when all or none selected (show everything); filter when partially selected
|
||||
const severityParam =
|
||||
severities.size === 0 || severities.size === ALL_SEVERITIES.length
|
||||
? undefined
|
||||
: [...severities].join(',');
|
||||
|
||||
const { data: eventsData, isLoading } = useQuery({
|
||||
queryKey: ['events', { search, severityParam, topic, deviceId, page }],
|
||||
queryFn: () =>
|
||||
eventsApi
|
||||
.list({
|
||||
search: search || undefined,
|
||||
severity: severityParam,
|
||||
topic: topic || undefined,
|
||||
deviceId: deviceId ? parseInt(deviceId) : undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => eventsApi.clear(deviceId ? parseInt(deviceId) : undefined),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['events'] }); setPage(0); },
|
||||
});
|
||||
|
||||
useSocket({
|
||||
'events:updated': () => queryClient.invalidateQueries({ queryKey: ['events'] }),
|
||||
});
|
||||
|
||||
const events = eventsData?.events ?? [];
|
||||
const total = eventsData?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Events
|
||||
{total > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||||
({total.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
{eventsData?.criticalCount ? (
|
||||
<span className="ml-2 px-2 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-xs rounded-full font-medium">
|
||||
{eventsData.criticalCount} critical (24h)
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Clear all events? This cannot be undone.')) clearMutation.mutate();
|
||||
}}
|
||||
className="btn-secondary flex items-center gap-2 text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-full sm:flex-1 sm:min-w-48">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Search messages or topics…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full sm:w-36"
|
||||
placeholder="Filter topic…"
|
||||
value={topic}
|
||||
onChange={(e) => { setTopic(e.target.value); setPage(0); }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800">
|
||||
{([
|
||||
{ key: 'error', label: 'Error', color: 'text-red-500' },
|
||||
{ key: 'warning', label: 'Warning', color: 'text-yellow-500' },
|
||||
{ key: 'info', label: 'Info', color: 'text-blue-500' },
|
||||
] as const).map(({ key, label, color }) => (
|
||||
<label key={key} className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={severities.has(key)}
|
||||
onChange={() => toggleSeverity(key)}
|
||||
className="w-3.5 h-3.5 rounded accent-current cursor-pointer"
|
||||
/>
|
||||
<span className={clsx('text-xs font-medium', color)}>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="input w-full sm:w-44"
|
||||
value={deviceId}
|
||||
onChange={(e) => { setDeviceId(e.target.value); setPage(0); }}
|
||||
>
|
||||
<option value="">All devices</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-3 text-center">
|
||||
<Bell className="w-12 h-12 text-gray-300 dark:text-slate-600" />
|
||||
<p className="text-gray-500 dark:text-slate-400">No events found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Time</th>
|
||||
<th className="table-header px-4 py-2.5 text-left w-24">Severity</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Device</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Topic</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{events.map((ev) => {
|
||||
const Icon = severityIcon(ev.severity);
|
||||
return (
|
||||
<tr
|
||||
key={ev.id}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50 dark:hover:bg-slate-700/30',
|
||||
(ev.severity === 'error' || ev.severity === 'critical') &&
|
||||
'bg-red-50/30 dark:bg-red-900/10'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5 text-xs font-mono text-gray-500 dark:text-slate-400 whitespace-nowrap">
|
||||
{format(new Date(ev.event_time), 'MMM d, HH:mm:ss')}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={clsx('inline-flex items-center gap-1 text-xs font-medium', severityColor(ev.severity))}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{ev.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
{ev.device_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs font-mono text-gray-400 dark:text-slate-500">
|
||||
{ev.topic || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-700 dark:text-slate-300">
|
||||
{ev.message}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-slate-400">
|
||||
Showing {(page * PAGE_SIZE + 1).toLocaleString()}–{Math.min((page + 1) * PAGE_SIZE, total).toLocaleString()} of {total.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="btn-secondary py-1 text-xs"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
className="btn-secondary py-1 text-xs"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Network, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { authApi } from '../services/api';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import CircuitBackground from '../components/CircuitBackground';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggleTheme } = useThemeStore();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await authApi.login(username, password);
|
||||
setAuth(data.token, data.user);
|
||||
navigate('/dashboard');
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Login failed. Please check your credentials.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex flex-col items-center justify-center p-4 relative overflow-hidden"
|
||||
style={{ backgroundColor: isDark ? '#040c07' : '#e8f2f7' }}
|
||||
>
|
||||
<CircuitBackground theme={theme} />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`absolute top-4 right-4 p-2 rounded-lg transition-colors z-10 ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{isDark ? '☀️' : '🌙'}
|
||||
</button>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className={`w-14 h-14 bg-blue-600 rounded-2xl flex items-center justify-center mb-4 ${
|
||||
isDark ? 'shadow-lg shadow-blue-900/50' : 'shadow-lg shadow-blue-400/40'
|
||||
}`}>
|
||||
<Network className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 className={`text-2xl font-bold drop-shadow-lg ${isDark ? 'text-white' : 'text-slate-800'}`}>
|
||||
Mikrotik Manager
|
||||
</h1>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-green-400/70' : 'text-cyan-700/80'}`}>
|
||||
Network Management Platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login card */}
|
||||
<div className={`backdrop-blur-sm rounded-xl shadow-2xl p-6 ${
|
||||
isDark
|
||||
? 'bg-slate-900/80 border border-slate-700/60 shadow-black/60'
|
||||
: 'bg-white/75 border border-slate-300/60 shadow-slate-400/30'
|
||||
}`}>
|
||||
<h2 className={`text-lg font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-800'}`}>
|
||||
Sign in to your account
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}
|
||||
htmlFor="username"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className={`w-full px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-800/80 border border-slate-600 text-white placeholder-slate-500'
|
||||
: 'bg-white/80 border border-slate-300 text-slate-800 placeholder-slate-400'
|
||||
}`}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={`w-full px-3 py-2 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-800/80 border border-slate-600 text-white placeholder-slate-500'
|
||||
: 'bg-white/80 border border-slate-300 text-slate-800 placeholder-slate-400'
|
||||
}`}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 transition-colors ${
|
||||
isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg ${
|
||||
isDark
|
||||
? 'bg-red-900/30 border border-red-700/50'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}>
|
||||
<AlertCircle className={`w-4 h-4 flex-shrink-0 ${isDark ? 'text-red-400' : 'text-red-500'}`} />
|
||||
<p className={`text-sm ${isDark ? 'text-red-300' : 'text-red-600'}`}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className={`text-center text-xs mt-6 ${isDark ? 'text-slate-600' : 'text-slate-500'}`}>
|
||||
Default credentials: admin / admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Server, RefreshCw, AlertTriangle, Power, Plus, Pencil, Trash2,
|
||||
ChevronDown, ChevronRight, X, Check,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
|
||||
type NS = Record<string, string>;
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function leaseLabel(secs: string): string {
|
||||
const n = parseInt(secs);
|
||||
if (!n || isNaN(n)) return secs || '—';
|
||||
if (n < 3600) return `${Math.round(n / 60)} min`;
|
||||
if (n < 86400) return `${Math.round(n / 3600)} hr`;
|
||||
return `${Math.round(n / 86400)} d`;
|
||||
}
|
||||
|
||||
function StatusBadge({ disabled }: { disabled: boolean }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
disabled
|
||||
? 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', disabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
{disabled ? 'Disabled' : 'Enabled'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Server form modal ────────────────────────────────────────────────────────
|
||||
|
||||
interface ServerFormProps {
|
||||
protocol: 'ipv4' | 'ipv6';
|
||||
existing?: NS;
|
||||
pools: NS[];
|
||||
deviceId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ServerForm({ protocol, existing, pools, deviceId, onClose }: ServerFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(existing?.['name'] || '');
|
||||
const [iface, setIface] = useState(existing?.['interface'] || '');
|
||||
const [pool, setPool] = useState(existing?.['address-pool'] || '');
|
||||
const [leaseTime, setLeaseTime] = useState(existing?.['lease-time'] || '00:10:00');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS & { protocol: 'ipv4' | 'ipv6' } = {
|
||||
protocol, name, interface: iface, 'address-pool': pool, 'lease-time': leaseTime,
|
||||
};
|
||||
return existing?.['.id']
|
||||
? networkServicesApi.updateDhcpServer(deviceId, existing['.id'], body)
|
||||
: networkServicesApi.addDhcpServer(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{existing ? 'Edit' : 'Add'} {protocol === 'ipv4' ? 'IPv4' : 'IPv6'} DHCP Server
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="dhcp1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Interface *</label>
|
||||
<input className="input w-full" value={iface} onChange={e => setIface(e.target.value)} placeholder="bridge1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Address Pool</label>
|
||||
{pools.length > 0 ? (
|
||||
<select className="input w-full" value={pool} onChange={e => setPool(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{pools.map(p => <option key={p['.id']} value={p['name']}>{p['name']} ({p['ranges'] || p['prefix'] || ''})</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input w-full" value={pool} onChange={e => setPool(e.target.value)} placeholder="pool-name" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Lease Time</label>
|
||||
<input className="input w-full" value={leaseTime} onChange={e => setLeaseTime(e.target.value)} placeholder="00:10:00" />
|
||||
<p className="mt-0.5 text-xs text-gray-400">Format: HH:MM:SS or e.g. 10m, 1h, 1d</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!name || !iface || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 dark:text-slate-400 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pool form modal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface PoolFormProps {
|
||||
protocol: 'ipv4' | 'ipv6';
|
||||
deviceId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PoolForm({ protocol, deviceId, onClose }: PoolFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [ranges, setRanges] = useState('');
|
||||
const [prefix, setPrefix] = useState('');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const params: NS & { protocol: 'ipv4' | 'ipv6' } = { protocol, name };
|
||||
if (protocol === 'ipv4') params['ranges'] = ranges;
|
||||
else params['prefix'] = prefix;
|
||||
return networkServicesApi.addDhcpPool(deviceId, params);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Add {protocol === 'ipv4' ? 'IPv4' : 'IPv6'} Address Pool
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="pool1" />
|
||||
</div>
|
||||
{protocol === 'ipv4' ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Ranges *</label>
|
||||
<input className="input w-full" value={ranges} onChange={e => setRanges(e.target.value)} placeholder="192.168.1.10-192.168.1.100" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Prefix *</label>
|
||||
<input className="input w-full" value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="2001:db8::/64" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!name || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Adding…' : 'Add Pool'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Static Lease form modal ──────────────────────────────────────────────────
|
||||
|
||||
interface StaticLeaseFormProps {
|
||||
protocol: 'ipv4' | 'ipv6';
|
||||
servers: NS[];
|
||||
deviceId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function StaticLeaseForm({ protocol, servers, deviceId, onClose }: StaticLeaseFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [mac, setMac] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [server, setServer] = useState(servers[0]?.['name'] || '');
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS & { protocol: 'ipv4' | 'ipv6' } = {
|
||||
protocol, 'mac-address': mac, address, comment,
|
||||
...(protocol === 'ipv4' ? { server } : { server }),
|
||||
};
|
||||
return networkServicesApi.addStaticLease(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-leases', deviceId, protocol] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Add Static Lease</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">MAC Address *</label>
|
||||
<input className="input w-full font-mono" value={mac} onChange={e => setMac(e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">IP Address *</label>
|
||||
<input className="input w-full font-mono" value={address} onChange={e => setAddress(e.target.value)} placeholder={protocol === 'ipv4' ? '192.168.1.50' : '2001:db8::50'} />
|
||||
</div>
|
||||
{servers.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">DHCP Server</label>
|
||||
<select className="input w-full" value={server} onChange={e => setServer(e.target.value)}>
|
||||
{servers.map(s => <option key={s['.id']} value={s['name']}>{s['name']}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Comment</label>
|
||||
<input className="input w-full" value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!mac || !address || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Adding…' : 'Add Lease'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section component ────────────────────────────────────────────────────────
|
||||
|
||||
interface SectionProps {
|
||||
color: string;
|
||||
title: string;
|
||||
count: number;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ color, title, count, open, onToggle, action, children }: SectionProps) {
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center border-b border-gray-200 dark:border-slate-700">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 flex-1 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-800/40 transition-colors text-left"
|
||||
>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||
<Server className={clsx('w-4 h-4', color)} />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">{title}</h2>
|
||||
<span className="ml-2 text-xs text-gray-400 dark:text-slate-500">{count}</span>
|
||||
</button>
|
||||
{action && <div className="px-3">{action}</div>}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkServicesDHCPPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
||||
const [v4Open, setV4Open] = useState(true);
|
||||
const [v6Open, setV6Open] = useState(true);
|
||||
const [poolsV4Open, setPoolsV4Open] = useState(false);
|
||||
const [poolsV6Open, setPoolsV6Open] = useState(false);
|
||||
const [leasesOpen, setLeasesOpen] = useState(false);
|
||||
const [leasesV6Open, setLeasesV6Open] = useState(false);
|
||||
|
||||
// Modals
|
||||
const [serverForm, setServerForm] = useState<{ protocol: 'ipv4' | 'ipv6'; existing?: NS } | null>(null);
|
||||
const [poolForm, setPoolForm] = useState<'ipv4' | 'ipv6' | null>(null);
|
||||
const [leaseForm, setLeaseForm] = useState<'ipv4' | 'ipv6' | null>(null);
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0;
|
||||
const selectedDevice = devices.find(d => d.id === deviceId);
|
||||
|
||||
const { data: dhcp, isLoading, refetch, isFetching, error } = useQuery({
|
||||
queryKey: ['ns-dhcp', deviceId],
|
||||
queryFn: () => networkServicesApi.getDhcp(deviceId).then(r => r.data),
|
||||
enabled: deviceId > 0,
|
||||
});
|
||||
|
||||
const { data: leasesV4 = [] } = useQuery({
|
||||
queryKey: ['ns-leases', deviceId, 'ipv4'],
|
||||
queryFn: () => networkServicesApi.getLeases(deviceId, 'ipv4').then(r => r.data),
|
||||
enabled: deviceId > 0 && leasesOpen,
|
||||
});
|
||||
|
||||
const { data: leasesV6 = [] } = useQuery({
|
||||
queryKey: ['ns-leases', deviceId, 'ipv6'],
|
||||
queryFn: () => networkServicesApi.getLeases(deviceId, 'ipv6').then(r => r.data),
|
||||
enabled: deviceId > 0 && leasesV6Open,
|
||||
});
|
||||
|
||||
const deleteServer = useMutation({
|
||||
mutationFn: ({ id, protocol }: { id: string; protocol: 'ipv4' | 'ipv6' }) =>
|
||||
networkServicesApi.deleteDhcpServer(deviceId, id, protocol),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }),
|
||||
});
|
||||
|
||||
const toggleServer = useMutation({
|
||||
mutationFn: ({ id, disabled, protocol }: { id: string; disabled: boolean; protocol: 'ipv4' | 'ipv6' }) =>
|
||||
networkServicesApi.toggleDhcpServer(deviceId, id, disabled, protocol),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }),
|
||||
});
|
||||
|
||||
const deletePool = useMutation({
|
||||
mutationFn: ({ id, protocol }: { id: string; protocol: 'ipv4' | 'ipv6' }) =>
|
||||
networkServicesApi.deleteDhcpPool(deviceId, id, protocol),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }),
|
||||
});
|
||||
|
||||
const deleteLease = useMutation({
|
||||
mutationFn: ({ id, protocol }: { id: string; protocol: 'ipv4' | 'ipv6' }) =>
|
||||
networkServicesApi.deleteStaticLease(deviceId, id, protocol),
|
||||
onSuccess: (_d, vars) => qc.invalidateQueries({ queryKey: ['ns-leases', deviceId, vars.protocol] }),
|
||||
});
|
||||
|
||||
function ServerTable({ servers, protocol }: { servers: NS[]; protocol: 'ipv4' | 'ipv6' }) {
|
||||
if (servers.length === 0) {
|
||||
return <div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No servers configured.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Interface</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Pool</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Lease Time</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Status</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map((s, i) => {
|
||||
const disabled = s['disabled'] === 'true';
|
||||
return (
|
||||
<tr key={s['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{s['name'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{s['interface'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{s['address-pool'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{leaseLabel(s['lease-time'] || '')}</td>
|
||||
<td className="px-4 py-3"><StatusBadge disabled={disabled} /></td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => toggleServer.mutate({ id: s['.id'], disabled: !disabled, protocol })} title={disabled ? 'Enable' : 'Disable'}
|
||||
className={clsx('p-1.5 rounded-lg transition-colors', disabled ? 'text-gray-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20' : 'text-green-600 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20')}>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setServerForm({ protocol, existing: s })} title="Edit"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => { if (confirm(`Delete DHCP server "${s['name']}"?`)) deleteServer.mutate({ id: s['.id'], protocol }); }} title="Delete"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PoolTable({ pools, protocol }: { pools: NS[]; protocol: 'ipv4' | 'ipv6' }) {
|
||||
if (pools.length === 0) return <div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No pools configured.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">{protocol === 'ipv4' ? 'Ranges' : 'Prefix'}</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pools.map((p, i) => (
|
||||
<tr key={p['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{p['name']}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-300">{p['ranges'] || p['prefix'] || '—'}</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => { if (confirm(`Delete pool "${p['name']}"?`)) deletePool.mutate({ id: p['.id'], protocol }); }}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaseTable({ leases, protocol }: { leases: NS[]; protocol: 'ipv4' | 'ipv6' }) {
|
||||
if (leases.length === 0) return <div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No leases.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">MAC / DUID</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Address</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Hostname</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Type</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leases.map((l, i) => {
|
||||
const isStatic = l['dynamic'] !== 'true';
|
||||
return (
|
||||
<tr key={l['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700 dark:text-slate-300">{l['mac-address'] || l['duid'] || '—'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700 dark:text-slate-300">{l['address'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{l['host-name'] || l['comment'] || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={clsx('text-xs px-2 py-0.5 rounded-full font-medium', isStatic ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500')}>
|
||||
{isStatic ? 'Static' : 'Dynamic'}
|
||||
</span>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isStatic && (
|
||||
<button onClick={() => deleteLease.mutate({ id: l['.id'], protocol })}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Modals */}
|
||||
{serverForm && (
|
||||
<ServerForm
|
||||
protocol={serverForm.protocol}
|
||||
existing={serverForm.existing}
|
||||
pools={serverForm.protocol === 'ipv4' ? (dhcp?.pools_v4 || []) : (dhcp?.pools_v6 || [])}
|
||||
deviceId={deviceId}
|
||||
onClose={() => setServerForm(null)}
|
||||
/>
|
||||
)}
|
||||
{poolForm && <PoolForm protocol={poolForm} deviceId={deviceId} onClose={() => setPoolForm(null)} />}
|
||||
{leaseForm && (
|
||||
<StaticLeaseForm
|
||||
protocol={leaseForm}
|
||||
servers={leaseForm === 'ipv4' ? (dhcp?.ipv4 || []) : (dhcp?.ipv6 || [])}
|
||||
deviceId={deviceId}
|
||||
onClose={() => setLeaseForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">DHCP Server</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Manage DHCP servers, address pools, and leases</p>
|
||||
</div>
|
||||
{deviceId > 0 && (
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device selector */}
|
||||
<div className="card p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
|
||||
<select className="input w-full max-w-xs" value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
|
||||
<option value="">— Choose a device —</option>
|
||||
{devices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedDevice && selectedDevice.status !== 'online' && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline. Connection may fail.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
|
||||
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
|
||||
{deviceId > 0 && error && (
|
||||
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dhcp && (
|
||||
<>
|
||||
{/* IPv4 Servers */}
|
||||
<Section color="text-blue-500" title="IPv4 DHCP Servers" count={dhcp.ipv4.length}
|
||||
open={v4Open} onToggle={() => setV4Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setServerForm({ protocol: 'ipv4' })} className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<ServerTable servers={dhcp.ipv4} protocol="ipv4" />
|
||||
</Section>
|
||||
|
||||
{/* IPv4 Pools */}
|
||||
<Section color="text-cyan-500" title="IPv4 Address Pools" count={dhcp.pools_v4.length}
|
||||
open={poolsV4Open} onToggle={() => setPoolsV4Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setPoolForm('ipv4')} className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<PoolTable pools={dhcp.pools_v4} protocol="ipv4" />
|
||||
</Section>
|
||||
|
||||
{/* IPv4 Leases */}
|
||||
<Section color="text-sky-500" title="IPv4 Leases" count={leasesV4.length}
|
||||
open={leasesOpen} onToggle={() => setLeasesOpen(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setLeaseForm('ipv4')} className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Static</button> : undefined}>
|
||||
<LeaseTable leases={leasesV4} protocol="ipv4" />
|
||||
</Section>
|
||||
|
||||
{/* IPv6 Servers */}
|
||||
<Section color="text-indigo-500" title="IPv6 DHCP Servers" count={dhcp.ipv6.length}
|
||||
open={v6Open} onToggle={() => setV6Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setServerForm({ protocol: 'ipv6' })} className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<ServerTable servers={dhcp.ipv6} protocol="ipv6" />
|
||||
</Section>
|
||||
|
||||
{/* IPv6 Pools */}
|
||||
<Section color="text-violet-500" title="IPv6 Address Pools" count={dhcp.pools_v6.length}
|
||||
open={poolsV6Open} onToggle={() => setPoolsV6Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setPoolForm('ipv6')} className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<PoolTable pools={dhcp.pools_v6} protocol="ipv6" />
|
||||
</Section>
|
||||
|
||||
{/* IPv6 Leases */}
|
||||
<Section color="text-purple-500" title="IPv6 Bindings" count={leasesV6.length}
|
||||
open={leasesV6Open} onToggle={() => setLeasesV6Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setLeaseForm('ipv6')} className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Static</button> : undefined}>
|
||||
<LeaseTable leases={leasesV6} protocol="ipv6" />
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Globe, RefreshCw, AlertTriangle, Save, Trash2, Plus, Pencil, X, Check, Eraser,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
|
||||
type NS = Record<string, string>;
|
||||
|
||||
// ─── DNS record types ─────────────────────────────────────────────────────────
|
||||
|
||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'TXT', 'SRV'] as const;
|
||||
type RecordType = typeof RECORD_TYPES[number];
|
||||
|
||||
// ─── Static record form modal ─────────────────────────────────────────────────
|
||||
|
||||
interface RecordFormProps {
|
||||
deviceId: number;
|
||||
existing?: NS;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function RecordForm({ deviceId, existing, onClose }: RecordFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(existing?.['name'] || '');
|
||||
const [type, setType] = useState<RecordType>((existing?.['type'] as RecordType) || 'A');
|
||||
const [address, setAddress] = useState(existing?.['address'] || '');
|
||||
const [cname, setCname] = useState(existing?.['cname'] || '');
|
||||
const [text, setText] = useState(existing?.['text'] || '');
|
||||
const [ttl, setTtl] = useState(existing?.['ttl'] || '');
|
||||
const [disabled, setDisabled] = useState(existing?.['disabled'] === 'true');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS = { name, type };
|
||||
if (type === 'A' || type === 'AAAA') body['address'] = address;
|
||||
else if (type === 'CNAME') body['cname'] = cname;
|
||||
else if (type === 'TXT') body['text'] = text;
|
||||
else body['address'] = address; // MX, NS, PTR, SRV share address field
|
||||
if (ttl) body['ttl'] = ttl;
|
||||
body['disabled'] = disabled ? 'yes' : 'no';
|
||||
|
||||
return existing?.['.id']
|
||||
? networkServicesApi.updateDnsStatic(deviceId, existing['.id'], body)
|
||||
: networkServicesApi.addDnsStatic(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-dns', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
const valueLabel = type === 'CNAME' ? 'CNAME Target' : type === 'TXT' ? 'Text' : 'Address / Target';
|
||||
const valuePlaceholder =
|
||||
type === 'A' ? '192.168.1.10' :
|
||||
type === 'AAAA' ? '2001:db8::1' :
|
||||
type === 'CNAME' ? 'alias.example.com' :
|
||||
type === 'TXT' ? '"v=spf1 include:example.com ~all"' :
|
||||
type === 'MX' || type === 'NS' ? 'mail.example.com' :
|
||||
type === 'PTR' ? 'hostname.example.com' :
|
||||
'_sip._tcp.example.com';
|
||||
|
||||
const valueField = type === 'TXT'
|
||||
? <textarea className="input w-full h-20 resize-y" value={text} onChange={e => setText(e.target.value)} placeholder={valuePlaceholder} />
|
||||
: type === 'CNAME'
|
||||
? <input className="input w-full" value={cname} onChange={e => setCname(e.target.value)} placeholder={valuePlaceholder} />
|
||||
: <input className="input w-full" value={address} onChange={e => setAddress(e.target.value)} placeholder={valuePlaceholder} />;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{existing ? 'Edit' : 'Add'} DNS Record
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="hostname.local" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Type</label>
|
||||
<select className="input w-full" value={type} onChange={e => setType(e.target.value as RecordType)}>
|
||||
{RECORD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">{valueLabel} *</label>
|
||||
{valueField}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">TTL</label>
|
||||
<input className="input w-full" value={ttl} onChange={e => setTtl(e.target.value)} placeholder="e.g. 5m (blank = default)" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-0.5">
|
||||
<button role="switch" aria-checked={disabled}
|
||||
onClick={() => setDisabled(v => !v)}
|
||||
className={clsx('relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors mb-0.5', disabled ? 'bg-gray-300 dark:bg-slate-600' : 'bg-blue-600')}>
|
||||
<span className={clsx('pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform', disabled ? 'translate-x-0' : 'translate-x-4')} />
|
||||
</button>
|
||||
<span className="text-xs text-gray-600 dark:text-slate-300">Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button onClick={() => save.mutate()} disabled={!name || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkServicesDNSPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
||||
|
||||
// Settings form state
|
||||
const [serversInput, setServersInput] = useState('');
|
||||
const [allowRemote, setAllowRemote] = useState(false);
|
||||
const [maxUdpSize, setMaxUdpSize] = useState('');
|
||||
const [cacheSize, setCacheSize] = useState('');
|
||||
const [cacheMaxTtl, setCacheMaxTtl] = useState('');
|
||||
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||
|
||||
const [recordForm, setRecordForm] = useState<NS | null | 'new'>(null);
|
||||
const [flushMsg, setFlushMsg] = useState('');
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0;
|
||||
const selectedDevice = devices.find(d => d.id === deviceId);
|
||||
|
||||
const { data: dns, isLoading, refetch, isFetching, error } = useQuery({
|
||||
queryKey: ['ns-dns', deviceId],
|
||||
queryFn: () => networkServicesApi.getDns(deviceId).then(r => r.data),
|
||||
enabled: deviceId > 0,
|
||||
});
|
||||
|
||||
// Sync settings form
|
||||
useEffect(() => {
|
||||
if (dns?.settings) {
|
||||
setServersInput(dns.settings['servers'] || '');
|
||||
setAllowRemote(dns.settings['allow-remote-requests'] === 'yes');
|
||||
setMaxUdpSize(dns.settings['max-udp-packet-size'] || '');
|
||||
setCacheSize(dns.settings['cache-size'] || '');
|
||||
setCacheMaxTtl(dns.settings['cache-max-ttl'] || '');
|
||||
setSettingsDirty(false);
|
||||
}
|
||||
}, [dns?.settings]);
|
||||
|
||||
// Conflict detection
|
||||
const { data: overview = [] } = useQuery({
|
||||
queryKey: ['network-services-overview'],
|
||||
queryFn: () => networkServicesApi.overview().then(r => r.data as NS[]),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const conflictDevices = overview.filter(d => {
|
||||
if (parseInt(d['id']) === deviceId) return false;
|
||||
return (d['dns'] as unknown as { allow_remote: boolean } | null)?.allow_remote;
|
||||
});
|
||||
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: () => networkServicesApi.setDns(deviceId, {
|
||||
servers: serversInput, allow_remote_requests: allowRemote,
|
||||
max_udp_packet_size: maxUdpSize, cache_size: cacheSize, cache_max_ttl: cacheMaxTtl,
|
||||
}),
|
||||
onSuccess: () => { setSettingsDirty(false); qc.invalidateQueries({ queryKey: ['ns-dns', deviceId] }); qc.invalidateQueries({ queryKey: ['network-services-overview'] }); },
|
||||
});
|
||||
|
||||
const flushCache = useMutation({
|
||||
mutationFn: () => networkServicesApi.flushDns(deviceId),
|
||||
onSuccess: () => { setFlushMsg('Cache flushed'); setTimeout(() => setFlushMsg(''), 3000); },
|
||||
});
|
||||
|
||||
const deleteRecord = useMutation({
|
||||
mutationFn: (id: string) => networkServicesApi.deleteDnsStatic(deviceId, id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dns', deviceId] }),
|
||||
});
|
||||
|
||||
const statics = dns?.statics || [];
|
||||
|
||||
function mark() { setSettingsDirty(true); }
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Record form modal */}
|
||||
{recordForm && (
|
||||
<RecordForm
|
||||
deviceId={deviceId}
|
||||
existing={recordForm === 'new' ? undefined : recordForm as NS}
|
||||
onClose={() => setRecordForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">DNS Server</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">DNS resolver settings and static records</p>
|
||||
</div>
|
||||
{deviceId > 0 && (
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device selector */}
|
||||
<div className="card p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
|
||||
<select className="input w-full max-w-xs" value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
|
||||
<option value="">— Choose a device —</option>
|
||||
{devices.map(d => <option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>)}
|
||||
</select>
|
||||
{selectedDevice?.status !== 'online' && deviceId > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{conflictDevices.length > 0 && deviceId > 0 && allowRemote && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-sm text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>DNS remote requests also enabled on: <strong>{conflictDevices.map(d => d['name']).join(', ')}</strong>. Multiple DNS servers may cause resolution issues.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
|
||||
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
|
||||
{deviceId > 0 && error && (
|
||||
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dns && (
|
||||
<>
|
||||
{/* ─── Settings ────────────────────────────────────────────────────── */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">DNS Settings</h2>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => flushCache.mutate()}
|
||||
disabled={flushCache.isPending}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-gray-600 dark:text-slate-300 border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Eraser className="w-3 h-3" />
|
||||
{flushCache.isPending ? 'Flushing…' : 'Flush Cache'}
|
||||
</button>
|
||||
)}
|
||||
{flushMsg && <span className="text-xs text-green-600 dark:text-green-400">{flushMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Upstream DNS Servers</label>
|
||||
<input type="text" className="input w-full max-w-sm" value={serversInput}
|
||||
onChange={e => { setServersInput(e.target.value); mark(); }} placeholder="8.8.8.8,1.1.1.1" disabled={!canWrite} />
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-slate-500">Comma-separated upstream resolver addresses.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Max UDP Packet Size</label>
|
||||
<input type="text" className="input w-full" value={maxUdpSize}
|
||||
onChange={e => { setMaxUdpSize(e.target.value); mark(); }} placeholder="4096" disabled={!canWrite} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Cache Max TTL</label>
|
||||
<input type="text" className="input w-full" value={cacheMaxTtl}
|
||||
onChange={e => { setCacheMaxTtl(e.target.value); mark(); }} placeholder="e.g. 1d" disabled={!canWrite} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<button role="switch" aria-checked={allowRemote}
|
||||
onClick={() => { if (canWrite) { setAllowRemote(v => !v); mark(); } }}
|
||||
disabled={!canWrite}
|
||||
className={clsx('relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors mt-0.5',
|
||||
allowRemote ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600', !canWrite && 'opacity-50 cursor-not-allowed')}>
|
||||
<span className={clsx('pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform', allowRemote ? 'translate-x-4' : 'translate-x-0')} />
|
||||
</button>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-slate-200">Allow Remote Requests</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">Allow other devices to use this router as a DNS resolver.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only stats */}
|
||||
{(dns.settings['cache-used'] || dns.settings['cache-size'] || dns.settings['dynamic-servers']) && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 pt-3 border-t border-gray-100 dark:border-slate-800">
|
||||
{dns.settings['cache-size'] && <div><div className="text-xs text-gray-400 mb-0.5">Cache Size</div><div className="text-sm font-medium text-gray-900 dark:text-white">{dns.settings['cache-size']} KiB</div></div>}
|
||||
{dns.settings['cache-used'] && <div><div className="text-xs text-gray-400 mb-0.5">Cache Used</div><div className="text-sm font-medium text-gray-900 dark:text-white">{dns.settings['cache-used']} KiB</div></div>}
|
||||
{dns.settings['dynamic-servers'] && <div><div className="text-xs text-gray-400 mb-0.5">Dynamic Servers</div><div className="text-sm font-medium text-gray-900 dark:text-white">{dns.settings['dynamic-servers']}</div></div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && settingsDirty && (
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Save className="w-3.5 h-3.5" />{saveSettings.isPending ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const s = dns.settings;
|
||||
setServersInput(s['servers'] || ''); setAllowRemote(s['allow-remote-requests'] === 'yes');
|
||||
setMaxUdpSize(s['max-udp-packet-size'] || ''); setCacheSize(s['cache-size'] || '');
|
||||
setCacheMaxTtl(s['cache-max-ttl'] || ''); setSettingsDirty(false);
|
||||
}} className="text-sm text-gray-500 hover:text-gray-700">Discard</button>
|
||||
{saveSettings.isError && <span className="text-xs text-red-500">{(saveSettings.error as Error).message}</span>}
|
||||
</div>
|
||||
)}
|
||||
{canWrite && saveSettings.isSuccess && !settingsDirty && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">Saved</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Static Records ───────────────────────────────────────────────── */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-green-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Static DNS Records</h2>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500 ml-1">{statics.length}</span>
|
||||
{canWrite && (
|
||||
<button onClick={() => setRecordForm('new')}
|
||||
className="ml-auto flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />Add Record
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{statics.length === 0 ? (
|
||||
<div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No static records configured.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Type</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Value</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">TTL</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Status</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statics.map((r, i) => {
|
||||
const isDisabled = r['disabled'] === 'true';
|
||||
const value = r['address'] || r['cname'] || r['text'] || '—';
|
||||
return (
|
||||
<tr key={r['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{r['name'] || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-mono font-medium">
|
||||
{r['type'] || 'A'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-300 max-w-xs truncate">{value}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-slate-400 text-xs">{r['ttl'] || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
isDisabled ? 'bg-gray-100 dark:bg-slate-700 text-gray-500' : 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', isDisabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
{isDisabled ? 'Disabled' : 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setRecordForm(r)} className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"><Pencil className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => { if (confirm(`Delete record "${r['name']}"?`)) deleteRecord.mutate(r['.id']); }}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Clock, RefreshCw, AlertTriangle, Save } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
role="switch" aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={clsx(
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors',
|
||||
checked ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<span className={clsx('pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform', checked ? 'translate-x-4' : 'translate-x-0')} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="pt-0.5">{children}</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-slate-200">{label}</div>
|
||||
{description && <p className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkServicesNTPPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
||||
|
||||
// Server form
|
||||
const [serverEnabled, setServerEnabled] = useState(false);
|
||||
const [serverBroadcast, setServerBroadcast] = useState(false);
|
||||
const [serverManycast, setServerManycast] = useState(false);
|
||||
|
||||
// Client form
|
||||
const [clientEnabled, setClientEnabled] = useState(false);
|
||||
const [clientMode, setClientMode] = useState('unicast');
|
||||
const [clientServers, setClientServers] = useState('');
|
||||
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0;
|
||||
const selectedDevice = devices.find(d => d.id === deviceId);
|
||||
|
||||
const { data: ntp, isLoading, refetch, isFetching, error } = useQuery({
|
||||
queryKey: ['ns-ntp', deviceId],
|
||||
queryFn: () => networkServicesApi.getNtp(deviceId).then(r => r.data),
|
||||
enabled: deviceId > 0,
|
||||
});
|
||||
|
||||
// Conflict detection
|
||||
const { data: overview = [] } = useQuery({
|
||||
queryKey: ['network-services-overview'],
|
||||
queryFn: () => networkServicesApi.overview().then(r => r.data as Record<string, unknown>[]),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const conflictDevices = overview.filter(d => {
|
||||
if ((d.id as number) === deviceId) return false;
|
||||
const n = d.ntp as { server_enabled: boolean } | null;
|
||||
return n?.server_enabled;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (ntp) {
|
||||
setServerEnabled(ntp.server?.['enabled'] === 'yes');
|
||||
setServerBroadcast(ntp.server?.['broadcast'] === 'yes');
|
||||
setServerManycast(ntp.server?.['manycast'] === 'yes');
|
||||
setClientEnabled(ntp.client?.['enabled'] === 'yes');
|
||||
setClientMode(ntp.client?.['mode'] || 'unicast');
|
||||
setClientServers(ntp.client?.['servers'] || '');
|
||||
setDirty(false);
|
||||
}
|
||||
}, [ntp]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => networkServicesApi.setNtp(deviceId, {
|
||||
server_enabled: serverEnabled, server_broadcast: serverBroadcast, server_manycast: serverManycast,
|
||||
client_enabled: clientEnabled, client_mode: clientMode, client_servers: clientServers,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setDirty(false);
|
||||
qc.invalidateQueries({ queryKey: ['ns-ntp', deviceId] });
|
||||
qc.invalidateQueries({ queryKey: ['network-services-overview'] });
|
||||
},
|
||||
});
|
||||
|
||||
function mark() { setDirty(true); }
|
||||
|
||||
const serverHasData = ntp && Object.keys(ntp.server || {}).length > 0;
|
||||
const clientHasData = ntp && Object.keys(ntp.client || {}).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">NTP Server</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Network Time Protocol server and client configuration</p>
|
||||
</div>
|
||||
{deviceId > 0 && (
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device selector */}
|
||||
<div className="card p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
|
||||
<select className="input w-full max-w-xs" value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
|
||||
<option value="">— Choose a device —</option>
|
||||
{devices.map(d => <option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>)}
|
||||
</select>
|
||||
{selectedDevice?.status !== 'online' && deviceId > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conflict warning */}
|
||||
{conflictDevices.length > 0 && deviceId > 0 && serverEnabled && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-sm text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>NTP server also enabled on: <strong>{conflictDevices.map(d => d.name as string).join(', ')}</strong>. Multiple NTP servers may cause time drift.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
|
||||
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
|
||||
{deviceId > 0 && error && (
|
||||
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ntp && (
|
||||
<div className="space-y-4">
|
||||
{/* NTP Server */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">NTP Server</h2>
|
||||
<span className="ml-auto">
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
serverEnabled ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', serverEnabled ? 'bg-green-500' : 'bg-gray-400')} />
|
||||
{serverEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{!serverHasData ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">NTP server settings not available (requires RouterOS 7).</p>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow label="Enable NTP Server" description="Allow other devices on the network to sync their clocks to this device.">
|
||||
<Toggle checked={serverEnabled} onChange={v => { setServerEnabled(v); mark(); }} disabled={!canWrite} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Broadcast Mode" description="Periodically broadcast the current time on the local network segment.">
|
||||
<Toggle checked={serverBroadcast} onChange={v => { setServerBroadcast(v); mark(); }} disabled={!canWrite || !serverEnabled} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Manycast Mode" description="Respond to manycast NTP client requests.">
|
||||
<Toggle checked={serverManycast} onChange={v => { setServerManycast(v); mark(); }} disabled={!canWrite || !serverEnabled} />
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NTP Client */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-indigo-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">NTP Client</h2>
|
||||
<span className="ml-auto">
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
clientEnabled ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', clientEnabled ? 'bg-green-500' : 'bg-gray-400')} />
|
||||
{clientEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{!clientHasData ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">NTP client settings not available (requires RouterOS 7).</p>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow label="Enable NTP Client" description="Sync this device's clock to upstream NTP servers.">
|
||||
<Toggle checked={clientEnabled} onChange={v => { setClientEnabled(v); mark(); }} disabled={!canWrite} />
|
||||
</SettingRow>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Mode</label>
|
||||
<select className="input w-full max-w-xs" value={clientMode}
|
||||
onChange={e => { setClientMode(e.target.value); mark(); }} disabled={!canWrite || !clientEnabled}>
|
||||
<option value="unicast">Unicast (direct server)</option>
|
||||
<option value="broadcast">Broadcast (listen for broadcasts)</option>
|
||||
<option value="multicast">Multicast</option>
|
||||
<option value="manycast">Manycast (auto-discover)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-slate-500">
|
||||
Unicast is recommended for most setups.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(clientMode === 'unicast' || clientMode === 'manycast') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">NTP Servers</label>
|
||||
<input type="text" className="input w-full max-w-sm" value={clientServers}
|
||||
onChange={e => { setClientServers(e.target.value); mark(); }}
|
||||
placeholder="pool.ntp.org,time.cloudflare.com" disabled={!canWrite || !clientEnabled} />
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-slate-500">Comma-separated list of NTP server addresses.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ntp.client['status'] && (
|
||||
<div className="pt-3 border-t border-gray-100 dark:border-slate-800">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mb-0.5">Sync Status</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white capitalize">{ntp.client['status']}</div>
|
||||
</div>
|
||||
{ntp.client['synced-server'] && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mb-0.5">Synced Server</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{ntp.client['synced-server']}</div>
|
||||
</div>
|
||||
)}
|
||||
{ntp.client['last-adjustment'] && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mb-0.5">Last Adjustment</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{ntp.client['last-adjustment']}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save bar */}
|
||||
{canWrite && dirty && (
|
||||
<div className="card p-4 flex items-center gap-3">
|
||||
<button onClick={() => save.mutate()} disabled={save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Save className="w-3.5 h-3.5" />{save.isPending ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
if (ntp) {
|
||||
setServerEnabled(ntp.server?.['enabled'] === 'yes');
|
||||
setServerBroadcast(ntp.server?.['broadcast'] === 'yes');
|
||||
setServerManycast(ntp.server?.['manycast'] === 'yes');
|
||||
setClientEnabled(ntp.client?.['enabled'] === 'yes');
|
||||
setClientMode(ntp.client?.['mode'] || 'unicast');
|
||||
setClientServers(ntp.client?.['servers'] || '');
|
||||
setDirty(false);
|
||||
}
|
||||
}} className="text-sm text-gray-500 hover:text-gray-700">Discard</button>
|
||||
{save.isError && <span className="text-sm text-red-500">{(save.error as Error).message}</span>}
|
||||
{save.isSuccess && <span className="text-sm text-green-600 dark:text-green-400">Saved</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Server, RefreshCw, CheckCircle, XCircle, MinusCircle,
|
||||
Globe, Clock, Shield, Wifi,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi } from '../services/api';
|
||||
|
||||
interface DeviceServiceRow {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
dhcp_v4: { total: number; enabled: number } | null;
|
||||
dhcp_v6: { total: number; enabled: number } | null;
|
||||
dns: { allow_remote: boolean; servers: string } | null;
|
||||
ntp: { server_enabled: boolean; client_enabled: boolean } | null;
|
||||
wireguard: { total: number; running: number } | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ServiceCell({ active, label }: { active: boolean | null; label?: string }) {
|
||||
if (active === null) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400 dark:text-slate-500">
|
||||
<MinusCircle className="w-3.5 h-3.5" />
|
||||
{label ?? '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (active) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
{label ?? 'Active'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400 dark:text-slate-500">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
{label ?? 'Inactive'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkServicesOverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['network-services-overview'],
|
||||
queryFn: () => networkServicesApi.overview().then(r => r.data as unknown as DeviceServiceRow[]),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const serviceColumns = [
|
||||
{ key: 'dhcp_v4', label: 'DHCP v4', icon: Server, href: '/network-services/dhcp' },
|
||||
{ key: 'dhcp_v6', label: 'DHCP v6', icon: Server, href: '/network-services/dhcp' },
|
||||
{ key: 'dns', label: 'DNS', icon: Globe, href: '/network-services/dns' },
|
||||
{ key: 'ntp', label: 'NTP', icon: Clock, href: '/network-services/ntp' },
|
||||
{ key: 'wireguard', label: 'WireGuard', icon: Shield, href: '/network-services/wireguard' },
|
||||
] as const;
|
||||
|
||||
// KPI: for each service, how many devices have it active?
|
||||
const kpis = serviceColumns.map((col) => {
|
||||
const active = data.filter((d) => {
|
||||
const svc = d[col.key];
|
||||
if (!svc) return false;
|
||||
if (col.key === 'dhcp_v4' || col.key === 'dhcp_v6') {
|
||||
return (svc as { enabled: number }).enabled > 0;
|
||||
}
|
||||
if (col.key === 'dns') return (svc as { allow_remote: boolean }).allow_remote;
|
||||
if (col.key === 'ntp') return (svc as { server_enabled: boolean }).server_enabled || (svc as { client_enabled: boolean }).client_enabled;
|
||||
if (col.key === 'wireguard') return (svc as { running: number }).running > 0;
|
||||
return false;
|
||||
}).length;
|
||||
return { ...col, active, total: data.length };
|
||||
});
|
||||
|
||||
function renderCell(device: DeviceServiceRow, key: typeof serviceColumns[number]['key']) {
|
||||
const svc = device[key];
|
||||
if (device.error || !svc) {
|
||||
return <ServiceCell active={null} />;
|
||||
}
|
||||
if (key === 'dhcp_v4' || key === 'dhcp_v6') {
|
||||
const s = svc as { total: number; enabled: number };
|
||||
if (s.total === 0) return <ServiceCell active={null} label="None" />;
|
||||
return <ServiceCell active={s.enabled > 0} label={`${s.enabled}/${s.total} active`} />;
|
||||
}
|
||||
if (key === 'dns') {
|
||||
const s = svc as { allow_remote: boolean; servers: string };
|
||||
return <ServiceCell active={s.allow_remote} label={s.allow_remote ? 'Remote on' : 'Local only'} />;
|
||||
}
|
||||
if (key === 'ntp') {
|
||||
const s = svc as { server_enabled: boolean; client_enabled: boolean };
|
||||
const label = s.server_enabled ? 'Server on' : s.client_enabled ? 'Client only' : 'Disabled';
|
||||
return <ServiceCell active={s.server_enabled || s.client_enabled} label={label} />;
|
||||
}
|
||||
if (key === 'wireguard') {
|
||||
const s = svc as { total: number; running: number };
|
||||
if (s.total === 0) return <ServiceCell active={null} label="None" />;
|
||||
return <ServiceCell active={s.running > 0} label={`${s.running}/${s.total} up`} />;
|
||||
}
|
||||
return <ServiceCell active={null} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Network Services</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||
Service status across all online devices
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{kpis.map(({ key, label, icon: Icon, active, total, href }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => navigate(href)}
|
||||
className="card p-4 flex flex-col gap-2 text-left hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Icon className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">{label}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{active}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">of {total} device{total !== 1 ? 's' : ''}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-gray-400 dark:text-slate-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Per-Device Service Status</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading service status…</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400 dark:text-slate-500">No online devices found.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Device</th>
|
||||
{serviceColumns.map((col) => (
|
||||
<th key={col.key} className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((device, i) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className={clsx(
|
||||
'border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40',
|
||||
i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{device.name}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">{device.ip_address}</div>
|
||||
{device.error && (
|
||||
<div className="text-xs text-red-500 mt-0.5">Connection failed</div>
|
||||
)}
|
||||
</td>
|
||||
{serviceColumns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
{renderCell(device, col.key)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Shield, RefreshCw, AlertTriangle, Power, Plus, Pencil, Trash2,
|
||||
ChevronDown, ChevronRight, Key, Copy, Check, X,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
|
||||
type NS = Record<string, string>;
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function truncateKey(key: string): string {
|
||||
if (!key || key.length <= 16) return key;
|
||||
return `${key.slice(0, 8)}…${key.slice(-6)}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: string): string {
|
||||
const n = parseInt(bytes);
|
||||
if (!n || isNaN(n)) return '—';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }}
|
||||
className="p-1 rounded text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Interface form modal ─────────────────────────────────────────────────────
|
||||
|
||||
interface IfaceFormProps {
|
||||
deviceId: number;
|
||||
existing?: NS;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function IfaceForm({ deviceId, existing, onClose }: IfaceFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(existing?.['name'] || '');
|
||||
const [listenPort, setListenPort] = useState(existing?.['listen-port'] || '13231');
|
||||
const [mtu, setMtu] = useState(existing?.['mtu'] || '1420');
|
||||
const [disabled, setDisabled] = useState(existing?.['disabled'] === 'true');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS = { name, 'listen-port': listenPort, mtu, disabled: disabled ? 'yes' : 'no' };
|
||||
return existing?.['.id']
|
||||
? networkServicesApi.updateWireGuardInterface(deviceId, existing['.id'], body)
|
||||
: networkServicesApi.addWireGuardInterface(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{existing ? 'Edit' : 'Add'} WireGuard Interface
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="wireguard1" disabled={!!existing} />
|
||||
{!existing && <p className="mt-0.5 text-xs text-gray-400">RouterOS will auto-generate a key pair.</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Listen Port</label>
|
||||
<input className="input w-full" type="number" value={listenPort} onChange={e => setListenPort(e.target.value)} placeholder="13231" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">MTU</label>
|
||||
<input className="input w-full" type="number" value={mtu} onChange={e => setMtu(e.target.value)} placeholder="1420" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button role="switch" aria-checked={!disabled} onClick={() => setDisabled(v => !v)}
|
||||
className={clsx('relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors', !disabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600')}>
|
||||
<span className={clsx('pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform', !disabled ? 'translate-x-4' : 'translate-x-0')} />
|
||||
</button>
|
||||
<span className="text-sm text-gray-700 dark:text-slate-200">Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button onClick={() => save.mutate()} disabled={!name || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Check className="w-3.5 h-3.5" />{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Peer form modal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface PeerFormProps {
|
||||
deviceId: number;
|
||||
ifaceName: string;
|
||||
existing?: NS;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PeerForm({ deviceId, ifaceName, existing, onClose }: PeerFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [pubKey, setPubKey] = useState(existing?.['public-key'] || '');
|
||||
const [allowedAddr, setAllowedAddr] = useState(existing?.['allowed-address'] || '');
|
||||
const [endpointAddr, setEndpointAddr] = useState(existing?.['endpoint-address'] || '');
|
||||
const [endpointPort, setEndpointPort] = useState(existing?.['endpoint-port'] || '');
|
||||
const [keepalive, setKeepalive] = useState(existing?.['persistent-keepalive'] || '');
|
||||
const [presharedKey, setPresharedKey] = useState('');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS = {
|
||||
interface: ifaceName,
|
||||
'public-key': pubKey,
|
||||
'allowed-address': allowedAddr,
|
||||
};
|
||||
if (endpointAddr) body['endpoint-address'] = endpointAddr;
|
||||
if (endpointPort) body['endpoint-port'] = endpointPort;
|
||||
if (keepalive) body['persistent-keepalive'] = keepalive;
|
||||
if (presharedKey) body['preshared-key'] = presharedKey;
|
||||
|
||||
return existing?.['.id']
|
||||
? networkServicesApi.updateWireGuardPeer(deviceId, existing['.id'], body)
|
||||
: networkServicesApi.addWireGuardPeer(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{existing ? 'Edit' : 'Add'} Peer — {ifaceName}
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Peer Public Key *</label>
|
||||
<input className="input w-full font-mono text-xs" value={pubKey} onChange={e => setPubKey(e.target.value)} placeholder="base64-encoded WireGuard public key" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Allowed Addresses *</label>
|
||||
<input className="input w-full font-mono" value={allowedAddr} onChange={e => setAllowedAddr(e.target.value)} placeholder="10.0.0.2/32 or 0.0.0.0/0" />
|
||||
<p className="mt-0.5 text-xs text-gray-400">Comma-separated CIDRs this peer is allowed to use.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Endpoint Address</label>
|
||||
<input className="input w-full" value={endpointAddr} onChange={e => setEndpointAddr(e.target.value)} placeholder="peer.example.com or IP" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Endpoint Port</label>
|
||||
<input className="input w-full" type="number" value={endpointPort} onChange={e => setEndpointPort(e.target.value)} placeholder="51820" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Persistent Keepalive (s)</label>
|
||||
<input className="input w-full" type="number" value={keepalive} onChange={e => setKeepalive(e.target.value)} placeholder="25" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Preshared Key</label>
|
||||
<input className="input w-full font-mono text-xs" value={presharedKey} onChange={e => setPresharedKey(e.target.value)} placeholder="Optional" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button onClick={() => save.mutate()} disabled={!pubKey || !allowedAddr || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Check className="w-3.5 h-3.5" />{save.isPending ? 'Saving…' : 'Save Peer'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkServicesWireGuardPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
||||
const [expandedIface, setExpandedIface] = useState<string | null>(null);
|
||||
const [ifaceForm, setIfaceForm] = useState<NS | 'new' | null>(null);
|
||||
const [peerForm, setPeerForm] = useState<{ ifaceName: string; existing?: NS } | null>(null);
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0;
|
||||
const selectedDevice = devices.find(d => d.id === deviceId);
|
||||
|
||||
const { data: wg, isLoading, refetch, isFetching, error } = useQuery({
|
||||
queryKey: ['ns-wireguard', deviceId],
|
||||
queryFn: () => networkServicesApi.getWireGuard(deviceId).then(r => r.data),
|
||||
enabled: deviceId > 0,
|
||||
});
|
||||
|
||||
const toggleIface = useMutation({
|
||||
mutationFn: ({ interfaceId, disabled }: { interfaceId: string; disabled: boolean }) =>
|
||||
networkServicesApi.toggleWireGuard(deviceId, interfaceId, disabled),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }),
|
||||
});
|
||||
|
||||
const deleteIface = useMutation({
|
||||
mutationFn: (id: string) => networkServicesApi.deleteWireGuardInterface(deviceId, id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }),
|
||||
});
|
||||
|
||||
const deletePeer = useMutation({
|
||||
mutationFn: (id: string) => networkServicesApi.deleteWireGuardPeer(deviceId, id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }),
|
||||
});
|
||||
|
||||
const peersForIface = (name: string) => (wg?.peers || []).filter(p => p['interface'] === name);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Modals */}
|
||||
{ifaceForm && (
|
||||
<IfaceForm
|
||||
deviceId={deviceId}
|
||||
existing={ifaceForm === 'new' ? undefined : ifaceForm as NS}
|
||||
onClose={() => setIfaceForm(null)}
|
||||
/>
|
||||
)}
|
||||
{peerForm && (
|
||||
<PeerForm
|
||||
deviceId={deviceId}
|
||||
ifaceName={peerForm.ifaceName}
|
||||
existing={peerForm.existing}
|
||||
onClose={() => setPeerForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">WireGuard</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">WireGuard VPN interfaces and peers</p>
|
||||
</div>
|
||||
{deviceId > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite && (
|
||||
<button onClick={() => setIfaceForm('new')}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
<Plus className="w-3.5 h-3.5" />New Interface
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />Refresh
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device selector */}
|
||||
<div className="card p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
|
||||
<select className="input w-full max-w-xs" value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
|
||||
<option value="">— Choose a device —</option>
|
||||
{devices.map(d => <option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>)}
|
||||
</select>
|
||||
{selectedDevice?.status !== 'online' && deviceId > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
|
||||
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
|
||||
{deviceId > 0 && error && (
|
||||
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wg && wg.interfaces.length === 0 && deviceId > 0 && !isLoading && (
|
||||
<div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">
|
||||
No WireGuard interfaces configured.{canWrite && ' Click "New Interface" to add one.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wg && wg.interfaces.map(iface => {
|
||||
const id = iface['.id'] || '';
|
||||
const name = iface['name'] || id;
|
||||
const disabled = iface['disabled'] === 'true';
|
||||
const running = iface['running'] === 'true';
|
||||
const peers = peersForIface(name);
|
||||
const isExpanded = expandedIface === name;
|
||||
|
||||
return (
|
||||
<div key={id || name} className="card overflow-hidden">
|
||||
{/* Interface header */}
|
||||
<div className="flex items-center border-b border-gray-200 dark:border-slate-700">
|
||||
<button onClick={() => setExpandedIface(isExpanded ? null : name)}
|
||||
className="flex items-center gap-2 flex-1 px-5 py-3 text-left hover:bg-gray-50 dark:hover:bg-slate-800/40 transition-colors">
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||
<Shield className="w-4 h-4 text-indigo-500 flex-shrink-0" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-slate-200">{name}</span>
|
||||
{iface['listen-port'] && <span className="text-xs text-gray-400 dark:text-slate-500">:{iface['listen-port']}</span>}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
running && !disabled ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: disabled ? 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', running && !disabled ? 'bg-green-500' : disabled ? 'bg-gray-400' : 'bg-amber-400')} />
|
||||
{disabled ? 'Disabled' : running ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
{canWrite && id && (
|
||||
<>
|
||||
<button onClick={() => toggleIface.mutate({ interfaceId: id, disabled: !disabled })}
|
||||
title={disabled ? 'Enable' : 'Disable'}
|
||||
className={clsx('p-1.5 rounded-lg transition-colors',
|
||||
disabled ? 'text-gray-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'
|
||||
: 'text-green-600 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20')}>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setIfaceForm(iface)} title="Edit"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => { if (confirm(`Delete WireGuard interface "${name}" and all its peers?`)) deleteIface.mutate(id); }}
|
||||
title="Delete"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Interface details */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{iface['public-key'] && (
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mb-1 flex items-center gap-1">
|
||||
<Key className="w-3 h-3" /> Public Key
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-gray-700 dark:text-slate-300 break-all flex-1">{iface['public-key']}</code>
|
||||
<CopyButton text={iface['public-key']} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{iface['mtu'] && (
|
||||
<div><div className="text-xs text-gray-400 dark:text-slate-500 mb-0.5">MTU</div><div className="text-sm text-gray-900 dark:text-white">{iface['mtu']}</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peers */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
Peers ({peers.length})
|
||||
</h3>
|
||||
{canWrite && (
|
||||
<button onClick={() => setPeerForm({ ifaceName: name })}
|
||||
className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />Add Peer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{peers.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">No peers configured.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Public Key</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Allowed Addresses</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Endpoint</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">TX / RX</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Last Handshake</th>
|
||||
{canWrite && <th className="px-3 py-2 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{peers.map((peer, i) => (
|
||||
<tr key={peer['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs font-mono text-gray-700 dark:text-slate-300">{truncateKey(peer['public-key'] || '')}</code>
|
||||
{peer['public-key'] && <CopyButton text={peer['public-key']} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs font-mono text-gray-600 dark:text-slate-300">{peer['allowed-address'] || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 dark:text-slate-300">
|
||||
{peer['endpoint-address'] ? `${peer['endpoint-address']}:${peer['endpoint-port'] || '?'}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">
|
||||
{(peer['tx'] || peer['rx']) ? `${formatBytes(peer['tx'] || '0')} / ${formatBytes(peer['rx'] || '0')}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">
|
||||
{peer['last-handshake'] || '—'}
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setPeerForm({ ifaceName: name, existing: peer })}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => { if (confirm('Delete this peer?')) deletePeer.mutate(peer['.id']); }}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Radio, RefreshCw, CheckCircle, XCircle, AlertCircle, Check,
|
||||
HelpCircle, Shield, Eye, EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { routersApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface SnmpForm {
|
||||
enabled: boolean;
|
||||
community_name: string;
|
||||
version: 'v1' | 'v2c' | 'v3';
|
||||
contact: string;
|
||||
location: string;
|
||||
trap_target: string;
|
||||
auth_protocol: string;
|
||||
auth_password: string;
|
||||
priv_protocol: string;
|
||||
priv_password: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SNMP: SnmpForm = {
|
||||
enabled: true,
|
||||
community_name: 'public',
|
||||
version: 'v2c',
|
||||
contact: '',
|
||||
location: '',
|
||||
trap_target: '',
|
||||
auth_protocol: 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: 'none',
|
||||
priv_password: '',
|
||||
};
|
||||
|
||||
function PasswordInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
className="input pr-9"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
onClick={() => setShow(s => !s)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RouterSettingsPage() {
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
// ── LLDP state ──────────────────────────────────────────────────────────────
|
||||
const [lldpApplyResult, setLldpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [lldpApplyError, setLldpApplyError] = useState('');
|
||||
|
||||
const { data: lldpStatuses = [], isLoading: lldpLoading, refetch: refetchLldp, isFetching: lldpFetching } =
|
||||
useQuery({ queryKey: ['routers-lldp'], queryFn: () => routersApi.getLldpStatus().then(r => r.data) });
|
||||
|
||||
const setLldpMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => routersApi.setLldp(enabled),
|
||||
onSuccess: (res) => { setLldpApplyResult({ applied: res.data.applied, total: res.data.total }); setLldpApplyError(''); refetchLldp(); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setLldpApplyError(msg || 'Failed to apply LLDP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const allEnabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === true);
|
||||
const allDisabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === false);
|
||||
|
||||
// ── SNMP state ──────────────────────────────────────────────────────────────
|
||||
const [snmpForm, setSnmpForm] = useState<SnmpForm>(DEFAULT_SNMP);
|
||||
const [snmpApplyResult, setSnmpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [snmpApplyError, setSnmpApplyError] = useState('');
|
||||
const [snmpFormEdited, setSnmpFormEdited] = useState(false);
|
||||
|
||||
const sf = (patch: Partial<SnmpForm>) => { setSnmpForm(f => ({ ...f, ...patch })); setSnmpFormEdited(true); };
|
||||
|
||||
const { data: snmpStatuses = [], isLoading: snmpLoading, refetch: refetchSnmp, isFetching: snmpFetching } =
|
||||
useQuery({
|
||||
queryKey: ['routers-snmp'],
|
||||
queryFn: () => routersApi.getSnmpStatus().then(r => {
|
||||
const first = r.data.find(s => s.enabled != null && !s.error);
|
||||
if (first && !snmpFormEdited) {
|
||||
setSnmpForm({
|
||||
enabled: first.enabled ?? true,
|
||||
community_name: first.community_name ?? 'public',
|
||||
version: (first.version as 'v1' | 'v2c' | 'v3') ?? 'v2c',
|
||||
contact: first.contact ?? '',
|
||||
location: first.location ?? '',
|
||||
trap_target: first.trap_target ?? '',
|
||||
auth_protocol: first.auth_protocol ?? 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: first.priv_protocol ?? 'none',
|
||||
priv_password: '',
|
||||
});
|
||||
}
|
||||
return r.data;
|
||||
}),
|
||||
});
|
||||
|
||||
const setSnmpMutation = useMutation({
|
||||
mutationFn: () => routersApi.setSnmp(snmpForm),
|
||||
onSuccess: (res) => {
|
||||
setSnmpApplyResult({ applied: res.data.applied, total: res.data.total });
|
||||
setSnmpApplyError('');
|
||||
refetchSnmp();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSnmpApplyError(msg || 'Failed to apply SNMP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const isV3 = snmpForm.version === 'v3';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Router Settings</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Network-wide configuration applied to all managed routers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── LLDP Card ──────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Radio className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Link Layer Discovery Protocol (LLDP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
LLDP lets your routers announce themselves to neighboring devices and collect
|
||||
neighbor information. Enabling LLDP improves the accuracy of the network topology map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!lldpLoading && lldpStatuses.length > 0 && (
|
||||
<div className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
|
||||
allEnabled ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400' :
|
||||
allDisabled ? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400' :
|
||||
'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400'
|
||||
)}>
|
||||
{allEnabled ? <CheckCircle className="w-4 h-4" /> : allDisabled ? <XCircle className="w-4 h-4" /> : <HelpCircle className="w-4 h-4" />}
|
||||
{allEnabled ? 'LLDP is enabled on all online routers' :
|
||||
allDisabled ? 'LLDP is disabled on all online routers' :
|
||||
'LLDP state is mixed across routers'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking LLDP status on all routers…
|
||||
</div>
|
||||
) : lldpStatuses.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
||||
No online routers found. Routers must be online to check or change LLDP settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Router</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">LLDP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Protocols</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{lldpStatuses.map(r => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{r.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{r.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{r.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : r.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : r.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{r.protocol && r.protocol !== 'unknown' ? r.protocol : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {lldpApplyResult.applied} of {lldpApplyResult.total} routers
|
||||
</div>
|
||||
)}
|
||||
{lldpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{lldpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={() => refetchLldp()} disabled={lldpFetching} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', lldpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(true); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
|
||||
Enable LLDP on All
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(false); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <XCircle className="w-3.5 h-3.5" />}
|
||||
Disable LLDP on All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── SNMP Card ───────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-purple-50 dark:bg-purple-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Simple Network Management Protocol (SNMP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
Configure SNMP on all managed routers. Settings are applied network-wide.
|
||||
SNMPv3 provides authentication and optional encryption for secure monitoring.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!canWrite} className="space-y-4 disabled:opacity-60">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Enable SNMP</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Allow SNMP polling and trap generation on all routers
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sf({ enabled: !snmpForm.enabled })}
|
||||
className={clsx(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
snmpForm.enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
snmpForm.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div>
|
||||
<label className="label">SNMP Version</label>
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-slate-600 overflow-hidden w-fit">
|
||||
{(['v1', 'v2c', 'v3'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => sf({ version: v })}
|
||||
className={clsx(
|
||||
'px-5 py-2 text-sm font-medium transition-colors',
|
||||
snmpForm.version === v
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-gray-600 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{v.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-1.5">
|
||||
{snmpForm.version === 'v1' && 'SNMPv1 — community string, no encryption. Legacy use only.'}
|
||||
{snmpForm.version === 'v2c' && 'SNMPv2c — community string, supports 64-bit counters. Recommended for read-only monitoring.'}
|
||||
{snmpForm.version === 'v3' && 'SNMPv3 — username-based with authentication and optional encryption. Most secure.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Community / Username */}
|
||||
<div>
|
||||
<label className="label">{isV3 ? 'Username' : 'Community Name'}</label>
|
||||
<input
|
||||
className="input max-w-xs"
|
||||
value={snmpForm.community_name}
|
||||
onChange={e => sf({ community_name: e.target.value })}
|
||||
placeholder={isV3 ? 'snmpv3user' : 'public'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Global info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Contact</label>
|
||||
<input className="input" value={snmpForm.contact} onChange={e => sf({ contact: e.target.value })} placeholder="admin@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Location</label>
|
||||
<input className="input" value={snmpForm.location} onChange={e => sf({ location: e.target.value })} placeholder="Server Room A" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Trap Destination (optional)</label>
|
||||
<input className="input max-w-xs" value={snmpForm.trap_target} onChange={e => sf({ trap_target: e.target.value })} placeholder="192.168.1.100" />
|
||||
</div>
|
||||
|
||||
{/* SNMPv3 section */}
|
||||
{isV3 && (
|
||||
<div className="border border-purple-200 dark:border-purple-800/50 rounded-lg p-4 space-y-4 bg-purple-50/30 dark:bg-purple-900/10">
|
||||
<p className="text-xs font-semibold text-purple-700 dark:text-purple-400 uppercase tracking-wide">
|
||||
SNMPv3 Security
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Authentication Protocol</label>
|
||||
<select className="input" value={snmpForm.auth_protocol} onChange={e => sf({ auth_protocol: e.target.value })}>
|
||||
<option value="MD5">MD5</option>
|
||||
<option value="SHA1">SHA1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Authentication Password</label>
|
||||
<PasswordInput value={snmpForm.auth_password} onChange={v => sf({ auth_password: v })} placeholder="Leave blank to keep current" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Privacy (Encryption) Protocol</label>
|
||||
<select className="input" value={snmpForm.priv_protocol} onChange={e => sf({ priv_protocol: e.target.value })}>
|
||||
<option value="none">None (auth only)</option>
|
||||
<option value="DES">DES</option>
|
||||
<option value="AES">AES</option>
|
||||
</select>
|
||||
</div>
|
||||
{snmpForm.priv_protocol !== 'none' && (
|
||||
<div>
|
||||
<label className="label">Privacy Password</label>
|
||||
<PasswordInput value={snmpForm.priv_password} onChange={v => sf({ priv_password: v })} placeholder="Leave blank to keep current" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||
Security level:{' '}
|
||||
<span className="font-medium text-purple-700 dark:text-purple-300">
|
||||
{snmpForm.priv_protocol !== 'none' ? 'authPriv (auth + encryption)' : 'authNoPriv (auth only)'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Per-router SNMP status table */}
|
||||
{snmpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking SNMP status on all routers…
|
||||
</div>
|
||||
) : snmpStatuses.length > 0 && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Router</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">SNMP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Version</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Community / User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{snmpStatuses.map(r => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{r.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{r.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{r.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : r.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : r.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{r.version ? (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
||||
r.version === 'v3' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
|
||||
)}>
|
||||
{r.version.toUpperCase()}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{r.community_name ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snmpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {snmpApplyResult.applied} of {snmpApplyResult.total} routers
|
||||
</div>
|
||||
)}
|
||||
{snmpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{snmpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={() => { setSnmpApplyResult(null); refetchSnmp(); }} disabled={snmpFetching} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', snmpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setSnmpMutation.isPending || snmpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setSnmpApplyResult(null); setSnmpApplyError(''); setSnmpMutation.mutate(); }}
|
||||
>
|
||||
{setSnmpMutation.isPending
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Applying…</>
|
||||
: <><Check className="w-3.5 h-3.5" /> Apply to All Routers</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Router, CheckCircle, XCircle, HelpCircle, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { routersApi } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface RouterDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
status: string;
|
||||
last_seen?: string;
|
||||
ros_version?: string;
|
||||
firmware_version?: string;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
ifaces_up: number;
|
||||
ifaces_down: number;
|
||||
ifaces_disabled: number;
|
||||
ifaces_total: number;
|
||||
}
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' :
|
||||
status === 'offline' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' :
|
||||
'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' ? 'bg-green-500' : status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoutersPage() {
|
||||
const { data: routers = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routers'],
|
||||
queryFn: () => routersApi.overview().then(r => r.data as unknown as RouterDevice[]),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = routers.filter(r => r.status === 'online').length;
|
||||
const offline = routers.filter(r => r.status === 'offline').length;
|
||||
const unknown = routers.filter(r => r.status !== 'online' && r.status !== 'offline').length;
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Routers', value: routers.length, icon: Router, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Online', value: online, icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Offline', value: offline, icon: XCircle, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
{ label: 'Unknown', value: unknown, icon: HelpCircle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-64 text-gray-400"><RefreshCw className="w-5 h-5 animate-spin mr-2" />Loading routers…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Routers</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Overview of all managed router devices
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
<Link to="/devices" className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{kpis.map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<div key={label} className="card p-5 flex items-center gap-4">
|
||||
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', bg)}>
|
||||
<Icon className={clsx('w-5 h-5', color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{routers.length === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Router className="w-12 h-12 text-gray-300 dark:text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 dark:text-slate-400 font-medium">No routers found</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Add devices with type <span className="font-mono">router</span> to see them here.
|
||||
</p>
|
||||
<Link to="/devices" className="btn-primary inline-flex items-center gap-2 mt-4 text-sm">
|
||||
<ExternalLink className="w-4 h-4" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Router cards grid */}
|
||||
{routers.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{routers.map(router => {
|
||||
const ifacePct = router.ifaces_total > 0
|
||||
? Math.round((router.ifaces_up / router.ifaces_total) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div key={router.id} className="card p-5 flex flex-col gap-4">
|
||||
{/* Card header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{router.name}</h3>
|
||||
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 mt-0.5">{router.ip_address}</p>
|
||||
</div>
|
||||
<StatusBadge status={router.status} />
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{router.model && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Model</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium truncate">{router.model}</span>
|
||||
</>
|
||||
)}
|
||||
{router.ros_version && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">RouterOS</span>
|
||||
<span className="text-gray-900 dark:text-white font-mono">{router.ros_version}</span>
|
||||
</>
|
||||
)}
|
||||
{router.rack_name && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Rack</span>
|
||||
<span className="text-gray-900 dark:text-white">{router.rack_name}{router.rack_slot ? ` / ${router.rack_slot}` : ''}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500 dark:text-slate-400">Last seen</span>
|
||||
<span className="text-gray-900 dark:text-white">{timeAgo(router.last_seen)}</span>
|
||||
</div>
|
||||
|
||||
{/* Interface utilisation */}
|
||||
{router.ifaces_total > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500 dark:text-slate-400">Interfaces</span>
|
||||
<span className="text-gray-700 dark:text-slate-300">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">{router.ifaces_up}</span>
|
||||
{' / '}
|
||||
{router.ifaces_total} up
|
||||
{router.ifaces_down > 0 && <span className="text-red-500 ml-1">· {router.ifaces_down} down</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${ifacePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<Link
|
||||
to={`/devices/${router.id}`}
|
||||
className="btn-secondary text-xs py-1.5 text-center"
|
||||
>
|
||||
View Device →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Layers, CheckCircle, XCircle, HelpCircle, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { switchesApi } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface SwitchDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
status: string;
|
||||
last_seen?: string;
|
||||
ros_version?: string;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
ports_up: number;
|
||||
ports_down: number;
|
||||
ports_disabled: number;
|
||||
ports_total: number;
|
||||
}
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' :
|
||||
status === 'offline' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' :
|
||||
'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' ? 'bg-green-500' : status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SwitchesOverviewPage() {
|
||||
const { data: switches = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['switches'],
|
||||
queryFn: () => switchesApi.list().then(r => r.data as unknown as SwitchDevice[]),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = switches.filter(s => s.status === 'online').length;
|
||||
const offline = switches.filter(s => s.status === 'offline').length;
|
||||
const unknown = switches.filter(s => s.status !== 'online' && s.status !== 'offline').length;
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Switches', value: switches.length, icon: Layers, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Online', value: online, icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Offline', value: offline, icon: XCircle, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
{ label: 'Unknown', value: unknown, icon: HelpCircle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-64 text-gray-400"><RefreshCw className="w-5 h-5 animate-spin mr-2" />Loading switches…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Switches</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Overview of all managed switch devices
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
<Link to="/devices" className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{kpis.map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<div key={label} className="card p-5 flex items-center gap-4">
|
||||
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', bg)}>
|
||||
<Icon className={clsx('w-5 h-5', color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No switches */}
|
||||
{switches.length === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Layers className="w-12 h-12 text-gray-300 dark:text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 dark:text-slate-400 font-medium">No switches found</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Add devices with type <span className="font-mono">switch</span> to see them here.
|
||||
</p>
|
||||
<Link to="/devices" className="btn-primary inline-flex items-center gap-2 mt-4 text-sm">
|
||||
<ExternalLink className="w-4 h-4" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Switch cards grid */}
|
||||
{switches.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{switches.map(sw => {
|
||||
const portPct = sw.ports_total > 0 ? Math.round((sw.ports_up / sw.ports_total) * 100) : 0;
|
||||
return (
|
||||
<div key={sw.id} className="card p-5 flex flex-col gap-4">
|
||||
{/* Card header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{sw.name}</h3>
|
||||
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 mt-0.5">{sw.ip_address}</p>
|
||||
</div>
|
||||
<StatusBadge status={sw.status} />
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{sw.model && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Model</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium truncate">{sw.model}</span>
|
||||
</>
|
||||
)}
|
||||
{sw.ros_version && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">RouterOS</span>
|
||||
<span className="text-gray-900 dark:text-white font-mono">{sw.ros_version}</span>
|
||||
</>
|
||||
)}
|
||||
{sw.rack_name && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Rack</span>
|
||||
<span className="text-gray-900 dark:text-white">{sw.rack_name}{sw.rack_slot ? ` / ${sw.rack_slot}` : ''}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500 dark:text-slate-400">Last seen</span>
|
||||
<span className="text-gray-900 dark:text-white">{timeAgo(sw.last_seen)}</span>
|
||||
</div>
|
||||
|
||||
{/* Port utilisation */}
|
||||
{sw.ports_total > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500 dark:text-slate-400">Ports</span>
|
||||
<span className="text-gray-700 dark:text-slate-300">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">{sw.ports_up}</span>
|
||||
{' / '}
|
||||
{sw.ports_total} up
|
||||
{sw.ports_down > 0 && <span className="text-red-500 ml-1">· {sw.ports_down} down</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${portPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<Link
|
||||
to={`/devices/${sw.id}`}
|
||||
className="btn-secondary text-xs py-1.5 text-center"
|
||||
>
|
||||
View Device →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Radio, RefreshCw, CheckCircle, XCircle, AlertCircle, Check,
|
||||
HelpCircle, Shield, Eye, EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { switchesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── SNMP form state ──────────────────────────────────────────────────────────
|
||||
interface SnmpForm {
|
||||
enabled: boolean;
|
||||
community_name: string;
|
||||
version: 'v1' | 'v2c' | 'v3';
|
||||
contact: string;
|
||||
location: string;
|
||||
trap_target: string;
|
||||
auth_protocol: string;
|
||||
auth_password: string;
|
||||
priv_protocol: string;
|
||||
priv_password: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SNMP: SnmpForm = {
|
||||
enabled: true,
|
||||
community_name: 'public',
|
||||
version: 'v2c',
|
||||
contact: '',
|
||||
location: '',
|
||||
trap_target: '',
|
||||
auth_protocol: 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: 'none',
|
||||
priv_password: '',
|
||||
};
|
||||
|
||||
function PasswordInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
className="input pr-9"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
onClick={() => setShow(s => !s)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function SwitchesSettingsPage() {
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
// ── LLDP state ────────────────────────────────────────────────────────────
|
||||
const [lldpApplyResult, setLldpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [lldpApplyError, setLldpApplyError] = useState('');
|
||||
|
||||
const { data: lldpStatuses = [], isLoading: lldpLoading, refetch: refetchLldp, isFetching: lldpFetching } =
|
||||
useQuery({ queryKey: ['switches-lldp'], queryFn: () => switchesApi.getLldpStatus().then(r => r.data) });
|
||||
|
||||
const setLldpMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => switchesApi.setLldp(enabled),
|
||||
onSuccess: (res) => { setLldpApplyResult({ applied: res.data.applied, total: res.data.total }); setLldpApplyError(''); refetchLldp(); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setLldpApplyError(msg || 'Failed to apply LLDP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const allEnabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === true);
|
||||
const allDisabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === false);
|
||||
|
||||
// ── SNMP state ────────────────────────────────────────────────────────────
|
||||
const [snmpForm, setSnmpForm] = useState<SnmpForm>(DEFAULT_SNMP);
|
||||
const [snmpApplyResult, setSnmpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [snmpApplyError, setSnmpApplyError] = useState('');
|
||||
const [snmpFormEdited, setSnmpFormEdited] = useState(false);
|
||||
|
||||
const sf = (patch: Partial<SnmpForm>) => { setSnmpForm(f => ({ ...f, ...patch })); setSnmpFormEdited(true); };
|
||||
|
||||
const { data: snmpStatuses = [], isLoading: snmpLoading, refetch: refetchSnmp, isFetching: snmpFetching } =
|
||||
useQuery({
|
||||
queryKey: ['switches-snmp'],
|
||||
queryFn: () => switchesApi.getSnmpStatus().then(r => {
|
||||
// Pre-populate form from first successful result (if form not yet manually edited)
|
||||
const first = r.data.find(s => s.enabled != null && !s.error);
|
||||
if (first && !snmpFormEdited) {
|
||||
setSnmpForm({
|
||||
enabled: first.enabled ?? true,
|
||||
community_name: first.community_name ?? 'public',
|
||||
version: (first.version as 'v1' | 'v2c' | 'v3') ?? 'v2c',
|
||||
contact: first.contact ?? '',
|
||||
location: first.location ?? '',
|
||||
trap_target: first.trap_target ?? '',
|
||||
auth_protocol: first.auth_protocol ?? 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: first.priv_protocol ?? 'none',
|
||||
priv_password: '',
|
||||
});
|
||||
}
|
||||
return r.data;
|
||||
}),
|
||||
});
|
||||
|
||||
const setSnmpMutation = useMutation({
|
||||
mutationFn: () => switchesApi.setSnmp(snmpForm),
|
||||
onSuccess: (res) => {
|
||||
setSnmpApplyResult({ applied: res.data.applied, total: res.data.total });
|
||||
setSnmpApplyError('');
|
||||
refetchSnmp();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSnmpApplyError(msg || 'Failed to apply SNMP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const isV3 = snmpForm.version === 'v3';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Switch Settings</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Network-wide configuration applied to all managed switches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── LLDP Card ────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Radio className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Link Layer Discovery Protocol (LLDP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
LLDP lets your switches announce themselves to neighboring devices and collect
|
||||
neighbor information. Enabling LLDP improves the accuracy of the network topology map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!lldpLoading && lldpStatuses.length > 0 && (
|
||||
<div className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
|
||||
allEnabled ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400' :
|
||||
allDisabled ? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400' :
|
||||
'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400'
|
||||
)}>
|
||||
{allEnabled ? <CheckCircle className="w-4 h-4" /> : allDisabled ? <XCircle className="w-4 h-4" /> : <HelpCircle className="w-4 h-4" />}
|
||||
{allEnabled ? 'LLDP is enabled on all online switches' :
|
||||
allDisabled ? 'LLDP is disabled on all online switches' :
|
||||
'LLDP state is mixed across switches'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking LLDP status on all switches…
|
||||
</div>
|
||||
) : lldpStatuses.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
||||
No online switches found. Switches must be online to check or change LLDP settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Switch</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">LLDP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Protocols</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{lldpStatuses.map(sw => (
|
||||
<tr key={sw.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{sw.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{sw.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{sw.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : sw.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : sw.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{sw.protocol && sw.protocol !== 'unknown' ? sw.protocol : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {lldpApplyResult.applied} of {lldpApplyResult.total} switches
|
||||
</div>
|
||||
)}
|
||||
{lldpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{lldpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={() => refetchLldp()} disabled={lldpFetching} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', lldpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(true); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
|
||||
Enable LLDP on All
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(false); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <XCircle className="w-3.5 h-3.5" />}
|
||||
Disable LLDP on All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── SNMP Card ─────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-purple-50 dark:bg-purple-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Simple Network Management Protocol (SNMP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
Configure SNMP on all managed switches. Settings are applied network-wide.
|
||||
SNMPv3 provides authentication and optional encryption for secure monitoring.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration form */}
|
||||
<fieldset disabled={!canWrite} className="space-y-4 disabled:opacity-60">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Enable SNMP</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Allow SNMP polling and trap generation on all switches
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sf({ enabled: !snmpForm.enabled })}
|
||||
className={clsx(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
snmpForm.enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
snmpForm.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div>
|
||||
<label className="label">SNMP Version</label>
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-slate-600 overflow-hidden w-fit">
|
||||
{(['v1', 'v2c', 'v3'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => sf({ version: v })}
|
||||
className={clsx(
|
||||
'px-5 py-2 text-sm font-medium transition-colors',
|
||||
snmpForm.version === v
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-gray-600 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{v.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-1.5">
|
||||
{snmpForm.version === 'v1' && 'SNMPv1 — community string, no encryption. Legacy use only.'}
|
||||
{snmpForm.version === 'v2c' && 'SNMPv2c — community string, supports 64-bit counters. Recommended for read-only monitoring.'}
|
||||
{snmpForm.version === 'v3' && 'SNMPv3 — username-based with authentication and optional encryption. Most secure.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Community / Username */}
|
||||
<div>
|
||||
<label className="label">{isV3 ? 'Username' : 'Community Name'}</label>
|
||||
<input
|
||||
className="input max-w-xs"
|
||||
value={snmpForm.community_name}
|
||||
onChange={e => sf({ community_name: e.target.value })}
|
||||
placeholder={isV3 ? 'snmpv3user' : 'public'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Global info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Contact</label>
|
||||
<input
|
||||
className="input"
|
||||
value={snmpForm.contact}
|
||||
onChange={e => sf({ contact: e.target.value })}
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Location</label>
|
||||
<input
|
||||
className="input"
|
||||
value={snmpForm.location}
|
||||
onChange={e => sf({ location: e.target.value })}
|
||||
placeholder="Server Room A"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Trap Destination (optional)</label>
|
||||
<input
|
||||
className="input max-w-xs"
|
||||
value={snmpForm.trap_target}
|
||||
onChange={e => sf({ trap_target: e.target.value })}
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SNMPv3 section */}
|
||||
{isV3 && (
|
||||
<div className="border border-purple-200 dark:border-purple-800/50 rounded-lg p-4 space-y-4 bg-purple-50/30 dark:bg-purple-900/10">
|
||||
<p className="text-xs font-semibold text-purple-700 dark:text-purple-400 uppercase tracking-wide">
|
||||
SNMPv3 Security
|
||||
</p>
|
||||
|
||||
{/* Auth */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Authentication Protocol</label>
|
||||
<select
|
||||
className="input"
|
||||
value={snmpForm.auth_protocol}
|
||||
onChange={e => sf({ auth_protocol: e.target.value })}
|
||||
>
|
||||
<option value="MD5">MD5</option>
|
||||
<option value="SHA1">SHA1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Authentication Password</label>
|
||||
<PasswordInput
|
||||
value={snmpForm.auth_password}
|
||||
onChange={v => sf({ auth_password: v })}
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Privacy (Encryption) Protocol</label>
|
||||
<select
|
||||
className="input"
|
||||
value={snmpForm.priv_protocol}
|
||||
onChange={e => sf({ priv_protocol: e.target.value })}
|
||||
>
|
||||
<option value="none">None (auth only)</option>
|
||||
<option value="DES">DES</option>
|
||||
<option value="AES">AES</option>
|
||||
</select>
|
||||
</div>
|
||||
{snmpForm.priv_protocol !== 'none' && (
|
||||
<div>
|
||||
<label className="label">Privacy Password</label>
|
||||
<PasswordInput
|
||||
value={snmpForm.priv_password}
|
||||
onChange={v => sf({ priv_password: v })}
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||
Security level:{' '}
|
||||
<span className="font-medium text-purple-700 dark:text-purple-300">
|
||||
{snmpForm.priv_protocol !== 'none' ? 'authPriv (auth + encryption)' : 'authNoPriv (auth only)'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Per-switch SNMP status table */}
|
||||
{snmpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking SNMP status on all switches…
|
||||
</div>
|
||||
) : snmpStatuses.length > 0 && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Switch</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">SNMP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Version</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Community / User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{snmpStatuses.map(sw => (
|
||||
<tr key={sw.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{sw.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{sw.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{sw.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : sw.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : sw.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{sw.version ? (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
||||
sw.version === 'v3' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
|
||||
)}>
|
||||
{sw.version.toUpperCase()}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{sw.community_name ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snmpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {snmpApplyResult.applied} of {snmpApplyResult.total} switches
|
||||
</div>
|
||||
)}
|
||||
{snmpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{snmpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
onClick={() => { setSnmpApplyResult(null); refetchSnmp(); }}
|
||||
disabled={snmpFetching}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', snmpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setSnmpMutation.isPending || snmpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setSnmpApplyResult(null); setSnmpApplyError(''); setSnmpMutation.mutate(); }}
|
||||
>
|
||||
{setSnmpMutation.isPending
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Applying…</>
|
||||
: <><Check className="w-3.5 h-3.5" /> Apply to All Switches</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
ReactFlow,
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
Handle,
|
||||
Position,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
BackgroundVariant,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { RefreshCw, GitBranch } from 'lucide-react';
|
||||
import { topologyApi } from '../services/api';
|
||||
import type { TopologyDevice, TopologyLink, ExternalTopologyNode } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// helper to derive device class from LLDP capabilities
|
||||
function capsLabel(caps: string | undefined): string {
|
||||
if (!caps) return '';
|
||||
const c = caps.toLowerCase();
|
||||
if (c.includes('bridge')) return 'Switch';
|
||||
if (c.includes('router')) return 'Router';
|
||||
if (c.includes('wlan-ap')) return 'AP';
|
||||
if (c.includes('telephone')) return 'Phone';
|
||||
return '';
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
online: '#22c55e',
|
||||
offline: '#ef4444',
|
||||
unknown: '#94a3b8',
|
||||
};
|
||||
|
||||
const deviceIcon: Record<string, string> = {
|
||||
router: '⇌',
|
||||
switch: '⊞',
|
||||
wireless_ap: '⊙',
|
||||
other: '◈',
|
||||
};
|
||||
|
||||
const handleStyle = { opacity: 0, width: 8, height: 8 };
|
||||
|
||||
function DeviceNode({ data }: { data: Record<string, unknown> }) {
|
||||
const device = data as unknown as TopologyDevice & { isRootBridge: boolean };
|
||||
return (
|
||||
<div
|
||||
className={clsx('card px-3 py-2 min-w-[140px] text-xs shadow-md')}
|
||||
style={{
|
||||
borderColor: device.isRootBridge ? '#f59e0b' : statusColor[device.status],
|
||||
borderWidth: device.isRootBridge ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="target" position={Position.Left} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Right} isConnectable={false} style={handleStyle} />
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-base leading-none">{deviceIcon[device.device_type] || '◈'}</span>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ background: statusColor[device.status] }}
|
||||
/>
|
||||
<span className="font-semibold truncate text-gray-900 dark:text-white">
|
||||
{device.name}
|
||||
</span>
|
||||
{device.isRootBridge && (
|
||||
<span title="Root Bridge" className="ml-auto text-amber-500 text-xs font-bold">★</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-gray-400 dark:text-slate-500">{device.ip_address}</div>
|
||||
{device.model && (
|
||||
<div className="text-gray-400 dark:text-slate-500 truncate">{device.model}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalNode({ data }: { data: Record<string, unknown> }) {
|
||||
const node = data as unknown as ExternalTopologyNode;
|
||||
|
||||
// Shared segment synthetic node
|
||||
if (node.caps === 'segment') {
|
||||
return (
|
||||
<div
|
||||
className="px-3 py-2 min-w-[140px] text-xs rounded-lg bg-amber-50 dark:bg-amber-900/20 shadow-sm"
|
||||
style={{ border: '2px dashed #f59e0b' }}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="target" position={Position.Left} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Right} isConnectable={false} style={handleStyle} />
|
||||
<div className="font-semibold text-amber-700 dark:text-amber-400 mb-0.5">⊕ Shared Segment</div>
|
||||
{node.platform && (
|
||||
<div className="text-amber-600 dark:text-amber-500">{node.platform}</div>
|
||||
)}
|
||||
<div className="text-amber-400 dark:text-amber-600 mt-0.5 text-[10px]">
|
||||
Unmanaged L2 switch/hub
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cl = capsLabel(node.caps);
|
||||
return (
|
||||
<div
|
||||
className="px-3 py-2 min-w-[130px] text-xs rounded-lg bg-gray-100 dark:bg-slate-700/60 shadow-sm"
|
||||
style={{ border: '1.5px dashed #94a3b8' }}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="target" position={Position.Left} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Right} isConnectable={false} style={handleStyle} />
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-base leading-none text-gray-400">◌</span>
|
||||
<span className="font-semibold truncate text-gray-600 dark:text-slate-300">
|
||||
{node.name}{cl ? ` (${cl})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{node.address && (
|
||||
<div className="font-mono text-gray-400 dark:text-slate-500">{node.address}</div>
|
||||
)}
|
||||
{node.platform && (
|
||||
<div className="text-gray-400 dark:text-slate-500 truncate">{node.platform}</div>
|
||||
)}
|
||||
<div className="text-gray-300 dark:text-slate-600 mt-0.5">External</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { deviceNode: DeviceNode, externalNode: ExternalNode };
|
||||
|
||||
const NODE_W = 160;
|
||||
const NODE_H = 80;
|
||||
const H_GAP = 60;
|
||||
const V_GAP = 90;
|
||||
|
||||
function buildGraph(
|
||||
devices: TopologyDevice[],
|
||||
externalNodes: ExternalTopologyNode[],
|
||||
links: TopologyLink[]
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
|
||||
// ── Shared-segment detection ────────────────────────────────────────────────
|
||||
// LLDP is 802.1AB point-to-point — always a direct link.
|
||||
// CDP and MNDP are L2 multicast and flood across unmanaged switches, so a port
|
||||
// seeing multiple non-LLDP neighbors means they're all on a shared segment,
|
||||
// not individually wired to this device.
|
||||
|
||||
const lldpLinks = links.filter((l) => l.link_type === 'lldp');
|
||||
const nonLldpLinks = links.filter((l) => l.link_type !== 'lldp' && !!l.from_device_id);
|
||||
|
||||
// Group non-LLDP links by (from_device_id, from_interface)
|
||||
const portGroupMap = new Map<string, TopologyLink[]>();
|
||||
for (const link of nonLldpLinks) {
|
||||
const pk = `${link.from_device_id}::${link.from_interface ?? ''}`;
|
||||
if (!portGroupMap.has(pk)) portGroupMap.set(pk, []);
|
||||
portGroupMap.get(pk)!.push(link);
|
||||
}
|
||||
|
||||
// Port groups with ≥2 neighbors → shared segment; 1 neighbor → keep as direct
|
||||
const sharedPortKeys = [...portGroupMap.keys()].filter((pk) => portGroupMap.get(pk)!.length >= 2);
|
||||
const soloNonLldp = [...portGroupMap.values()].filter((g) => g.length < 2).flat();
|
||||
|
||||
// Stable key to identify a neighbor across links
|
||||
const nKey = (l: TopologyLink) =>
|
||||
l.to_device_id ? `d:${l.to_device_id}` :
|
||||
l.neighbor_mac ? `m:${l.neighbor_mac.toLowerCase()}` :
|
||||
l.neighbor_address ? `a:${l.neighbor_address}` :
|
||||
`i:${l.neighbor_identity ?? ''}`;
|
||||
|
||||
// Union-find: merge port groups that see any common neighbor (same physical segment)
|
||||
const ufParent = new Map<string, string>(sharedPortKeys.map((k) => [k, k]));
|
||||
const ufFind = (k: string): string => {
|
||||
if (ufParent.get(k) !== k) ufParent.set(k, ufFind(ufParent.get(k)!));
|
||||
return ufParent.get(k)!;
|
||||
};
|
||||
const ufUnion = (a: string, b: string) => ufParent.set(ufFind(a), ufFind(b));
|
||||
|
||||
const pkNeighborSets = new Map<string, Set<string>>();
|
||||
for (const pk of sharedPortKeys) {
|
||||
pkNeighborSets.set(pk, new Set(portGroupMap.get(pk)!.map(nKey)));
|
||||
}
|
||||
for (let i = 0; i < sharedPortKeys.length; i++) {
|
||||
for (let j = i + 1; j < sharedPortKeys.length; j++) {
|
||||
const setA = pkNeighborSets.get(sharedPortKeys[i])!;
|
||||
for (const n of pkNeighborSets.get(sharedPortKeys[j])!) {
|
||||
if (setA.has(n)) { ufUnion(sharedPortKeys[i], sharedPortKeys[j]); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group port keys by segment root
|
||||
const segGroups = new Map<string, string[]>();
|
||||
for (const pk of sharedPortKeys) {
|
||||
const root = ufFind(pk);
|
||||
if (!segGroups.has(root)) segGroups.set(root, []);
|
||||
segGroups.get(root)!.push(pk);
|
||||
}
|
||||
|
||||
// IDs of all links absorbed into segments (not rendered as direct edges)
|
||||
const absorbedIds = new Set<number>(
|
||||
sharedPortKeys.flatMap((pk) => portGroupMap.get(pk)!.map((l) => l.id))
|
||||
);
|
||||
|
||||
// Build synthetic segment nodes and their connections
|
||||
interface SegConn { src: string; dst: string; port: string; }
|
||||
const segNodes: ExternalTopologyNode[] = [];
|
||||
const segConns: SegConn[] = [];
|
||||
|
||||
for (const [root, pks] of segGroups) {
|
||||
const segId = `seg-${root.replace(/[^a-z0-9]/gi, '')}`;
|
||||
|
||||
const srcDevPorts = new Map<string, string>(); // devId → local port
|
||||
const allDevIds = new Set<string>();
|
||||
const extNKeys = new Set<string>();
|
||||
|
||||
for (const pk of pks) {
|
||||
const colonIdx = pk.indexOf('::');
|
||||
const devId = pk.slice(0, colonIdx);
|
||||
const port = pk.slice(colonIdx + 2);
|
||||
srcDevPorts.set(devId, port);
|
||||
allDevIds.add(devId);
|
||||
for (const link of portGroupMap.get(pk)!) {
|
||||
if (link.to_device_id) allDevIds.add(String(link.to_device_id));
|
||||
else extNKeys.add(nKey(link));
|
||||
}
|
||||
}
|
||||
|
||||
segNodes.push({
|
||||
id: segId,
|
||||
name: 'Shared Segment',
|
||||
address: '',
|
||||
platform: `${allDevIds.size} managed devices`,
|
||||
mac: '',
|
||||
caps: 'segment', // sentinel for the ExternalNode renderer
|
||||
});
|
||||
|
||||
// Connect each source device to the segment node
|
||||
for (const [devId, port] of srcDevPorts) {
|
||||
segConns.push({ src: devId, dst: segId, port });
|
||||
}
|
||||
// Connect unmanaged external neighbors to the segment node
|
||||
for (const nk of extNKeys) {
|
||||
const ext = externalNodes.find((e) =>
|
||||
`m:${(e.mac || '').toLowerCase()}` === nk ||
|
||||
`a:${e.address}` === nk ||
|
||||
`i:${e.name}` === nk
|
||||
);
|
||||
if (ext) segConns.push({ src: ext.id, dst: segId, port: '' });
|
||||
}
|
||||
}
|
||||
|
||||
// Active direct links = LLDP + solo non-LLDP (single neighbor on that port)
|
||||
const activeLinks = [...lldpLinks, ...soloNonLldp];
|
||||
const allExtNodes = [...externalNodes, ...segNodes];
|
||||
|
||||
// ── Adjacency ───────────────────────────────────────────────────────────────
|
||||
const adj = new Map<string, Set<string>>();
|
||||
for (const d of devices) adj.set(String(d.id), new Set());
|
||||
for (const e of allExtNodes) adj.set(e.id, new Set());
|
||||
|
||||
const linkToExtId = new Map<number, string>();
|
||||
for (const link of activeLinks) {
|
||||
if (!link.from_device_id) continue;
|
||||
const src = String(link.from_device_id);
|
||||
let dst: string | null = null;
|
||||
if (link.to_device_id) {
|
||||
dst = String(link.to_device_id);
|
||||
} else {
|
||||
const ext = allExtNodes.find(
|
||||
(e) => (link.neighbor_address && e.address === link.neighbor_address) ||
|
||||
(link.neighbor_mac && e.mac === link.neighbor_mac) ||
|
||||
(!link.neighbor_address && !link.neighbor_mac && link.neighbor_identity && e.name === link.neighbor_identity)
|
||||
);
|
||||
if (ext) { dst = ext.id; linkToExtId.set(link.id, ext.id); }
|
||||
}
|
||||
if (dst && adj.has(src) && adj.has(dst)) {
|
||||
adj.get(src)!.add(dst); adj.get(dst)!.add(src);
|
||||
}
|
||||
}
|
||||
// Segment connections into adjacency
|
||||
for (const { src, dst } of segConns) {
|
||||
if (adj.has(src) && adj.has(dst)) {
|
||||
adj.get(src)!.add(dst); adj.get(dst)!.add(src);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Root / BFS levels ───────────────────────────────────────────────────────
|
||||
const hasStp = links.some((l) => l.stp_role);
|
||||
let rootId: string;
|
||||
if (hasStp) {
|
||||
const devicesWithRootPort = new Set(
|
||||
links.filter((l) => l.stp_role === 'root').map((l) => String(l.from_device_id))
|
||||
);
|
||||
const rootDevice = devices.find((d) => !devicesWithRootPort.has(String(d.id)));
|
||||
rootId = rootDevice ? String(rootDevice.id) : devices[0] ? String(devices[0].id) : '';
|
||||
} else {
|
||||
rootId = devices.reduce((best, d) =>
|
||||
(adj.get(String(d.id))?.size || 0) > (adj.get(best)?.size || 0) ? String(d.id) : best,
|
||||
devices[0] ? String(devices[0].id) : ''
|
||||
);
|
||||
}
|
||||
|
||||
const levels = new Map<string, number>();
|
||||
if (rootId) {
|
||||
const queue: string[] = [rootId];
|
||||
levels.set(rootId, 0);
|
||||
while (queue.length) {
|
||||
const curr = queue.shift()!;
|
||||
for (const neighbor of adj.get(curr) || []) {
|
||||
if (!levels.has(neighbor)) {
|
||||
levels.set(neighbor, levels.get(curr)! + 1);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxLevel = levels.size > 0 ? Math.max(...levels.values()) : 0;
|
||||
const allIds = [...devices.map((d) => String(d.id)), ...allExtNodes.map((e) => e.id)];
|
||||
for (const id of allIds) if (!levels.has(id)) levels.set(id, maxLevel + 1);
|
||||
|
||||
// ── Positioning ─────────────────────────────────────────────────────────────
|
||||
const byLevel = new Map<number, string[]>();
|
||||
for (const [id, lvl] of levels) {
|
||||
if (!byLevel.has(lvl)) byLevel.set(lvl, []);
|
||||
byLevel.get(lvl)!.push(id);
|
||||
}
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
for (const [lvl, ids] of byLevel) {
|
||||
const rowW = ids.length * NODE_W + (ids.length - 1) * H_GAP;
|
||||
ids.forEach((id, i) => {
|
||||
positions.set(id, { x: -rowW / 2 + i * (NODE_W + H_GAP), y: lvl * (NODE_H + V_GAP) });
|
||||
});
|
||||
}
|
||||
|
||||
// ── React Flow nodes ─────────────────────────────────────────────────────────
|
||||
const nodes: Node[] = [
|
||||
...devices.map((d) => ({
|
||||
id: String(d.id),
|
||||
type: 'deviceNode',
|
||||
position: positions.get(String(d.id)) || { x: 0, y: 0 },
|
||||
data: { ...d, isRootBridge: String(d.id) === rootId && hasStp } as unknown as Record<string, unknown>,
|
||||
})),
|
||||
...allExtNodes.map((e) => ({
|
||||
id: e.id,
|
||||
type: 'externalNode',
|
||||
position: positions.get(e.id) || { x: 0, y: (maxLevel + 1) * (NODE_H + V_GAP) },
|
||||
data: e as unknown as Record<string, unknown>,
|
||||
})),
|
||||
];
|
||||
|
||||
// ── React Flow edges ─────────────────────────────────────────────────────────
|
||||
const seen = new Set<string>();
|
||||
const edges: Edge[] = [];
|
||||
|
||||
// Direct links (LLDP + solo non-LLDP)
|
||||
for (const link of activeLinks) {
|
||||
if (!link.from_device_id) continue;
|
||||
const src = String(link.from_device_id);
|
||||
const dst = link.to_device_id ? String(link.to_device_id) : (linkToExtId.get(link.id) ?? null);
|
||||
if (!dst) continue;
|
||||
|
||||
const edgeKey = [src, dst].sort().join('--');
|
||||
if (seen.has(edgeKey)) continue;
|
||||
seen.add(edgeKey);
|
||||
|
||||
let stroke = '#94a3b8';
|
||||
let strokeDasharray: string | undefined;
|
||||
let animated = false;
|
||||
if (link.stp_role === 'root') { stroke = '#3b82f6'; animated = true; }
|
||||
else if (link.stp_role === 'designated') { stroke = '#22c55e'; }
|
||||
else if (link.stp_role === 'alternate' || link.stp_role === 'backup') {
|
||||
stroke = '#ef4444'; strokeDasharray = '6,3';
|
||||
}
|
||||
|
||||
const stpLabel = link.stp_role ? ` [${link.stp_role}]` : '';
|
||||
const portLabel = link.from_interface
|
||||
? (link.to_interface ? `${link.from_interface} ↔ ${link.to_interface}` : link.from_interface)
|
||||
: '';
|
||||
|
||||
edges.push({
|
||||
id: `edge-${link.id}`,
|
||||
source: src,
|
||||
target: dst,
|
||||
label: (portLabel + stpLabel) || undefined,
|
||||
labelStyle: { fontSize: 9, fill: '#94a3b8' },
|
||||
style: { stroke, strokeWidth: 2, strokeDasharray },
|
||||
animated,
|
||||
});
|
||||
}
|
||||
|
||||
// Shared-segment connection edges
|
||||
for (const { src, dst, port } of segConns) {
|
||||
const edgeKey = [src, dst].sort().join('--');
|
||||
if (seen.has(edgeKey)) continue;
|
||||
seen.add(edgeKey);
|
||||
edges.push({
|
||||
id: `segedge-${src}-${dst}`,
|
||||
source: src,
|
||||
target: dst,
|
||||
label: port || undefined,
|
||||
labelStyle: { fontSize: 9, fill: '#b45309' },
|
||||
style: { stroke: '#f59e0b', strokeWidth: 2, strokeDasharray: '5,3' },
|
||||
animated: false,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export default function TopologyPage() {
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['topology'],
|
||||
queryFn: () => topologyApi.get().then((r) => r.data),
|
||||
});
|
||||
|
||||
const discoverMutation = useMutation({
|
||||
mutationFn: () => topologyApi.discover(),
|
||||
onSuccess: () => setTimeout(() => refetch(), 3000),
|
||||
});
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const { nodes: n, edges: e } = buildGraph(
|
||||
(data.devices as TopologyDevice[]) || [],
|
||||
(data.externalNodes as ExternalTopologyNode[]) || [],
|
||||
(data.links as TopologyLink[]) || []
|
||||
);
|
||||
setNodes(n);
|
||||
setEdges(e);
|
||||
}, [data, setNodes, setEdges]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => setEdges((eds) => addEdge(connection, eds)),
|
||||
[setEdges]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96 text-gray-400">Loading topology...</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasData = (data?.devices?.length ?? 0) > 0;
|
||||
const hasStp = (data?.links as TopologyLink[])?.some((l) => l.stp_role);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Network Topology</h1>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => discoverMutation.mutate()}
|
||||
disabled={discoverMutation.isPending}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', discoverMutation.isPending && 'animate-spin')} />
|
||||
Discover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasData ? (
|
||||
<div className="card p-16 flex flex-col items-center gap-4 text-center">
|
||||
<GitBranch className="w-16 h-16 text-gray-300 dark:text-slate-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 dark:text-slate-300">No topology data yet</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Add devices and click "Discover" to map your network topology via LLDP neighbors.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => discoverMutation.mutate()}
|
||||
disabled={discoverMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', discoverMutation.isPending && 'animate-spin')} />
|
||||
Start Discovery
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card" style={{ height: 'min(600px, 60vh)' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.2}
|
||||
maxZoom={2}
|
||||
className="dark:bg-slate-800"
|
||||
>
|
||||
<Controls className="dark:bg-slate-700 dark:text-white" />
|
||||
<MiniMap
|
||||
className="dark:bg-slate-700"
|
||||
nodeColor={(n) => {
|
||||
if (n.type === 'externalNode') return '#94a3b8';
|
||||
const d = n.data as unknown as TopologyDevice;
|
||||
return statusColor[d.status] || '#94a3b8';
|
||||
}}
|
||||
/>
|
||||
<Background variant={BackgroundVariant.Dots} color="#94a3b8" gap={20} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="card px-4 py-3 flex flex-wrap gap-4 text-xs text-gray-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-0.5 bg-gray-400" />
|
||||
<span>Direct link (LLDP)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div style={{ width: 16, borderTop: '2px dashed #f59e0b' }} />
|
||||
<span>Shared segment (CDP/MNDP)</span>
|
||||
</div>
|
||||
{hasStp && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-0.5 bg-blue-500" />
|
||||
<span>Root port (uplink)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-0.5 bg-green-500" />
|
||||
<span>Designated (active)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div style={{ width: 16, borderTop: '2px dashed #ef4444' }} />
|
||||
<span>Alternate/Backup (blocked)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-amber-500 font-bold">★</span>
|
||||
<span>Root Bridge</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-4 rounded border-dashed border border-gray-400" />
|
||||
<span>External (unmanaged)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-4 rounded border-dashed border-amber-400" />
|
||||
<span>Shared segment node</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Link table */}
|
||||
{(data?.links?.length ?? 0) > 0 && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Discovered Links ({(data!.links as TopologyLink[]).length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">From Device</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Local Port</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Remote Port</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Neighbor</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Neighbor IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Protocol</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">STP Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(data!.links as TopologyLink[]).map((link) => (
|
||||
<tr key={link.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{link.from_device_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{link.from_interface || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{link.to_interface || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-700 dark:text-slate-300">
|
||||
{link.to_device_name || link.neighbor_identity || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{link.neighbor_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs">
|
||||
{link.link_type ? (
|
||||
<span className={clsx(
|
||||
'px-1.5 py-0.5 rounded font-medium uppercase',
|
||||
link.link_type === 'lldp' && 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
link.link_type === 'cdp' && 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
link.link_type === 'mndp' && 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
!['lldp','cdp','mndp'].includes(link.link_type) && 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300',
|
||||
)}>
|
||||
{link.link_type}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs">
|
||||
{link.stp_role ? (
|
||||
<span className={clsx(
|
||||
'px-1.5 py-0.5 rounded font-medium',
|
||||
link.stp_role === 'root' && 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
link.stp_role === 'designated' && 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
(link.stp_role === 'alternate' || link.stp_role === 'backup') && 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
)}>
|
||||
{link.stp_role}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RefreshCw, Wifi } from 'lucide-react';
|
||||
import { clientsApi } from '../services/api';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import type { Client } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function signalLabel(dbm: number | null): string {
|
||||
if (dbm === null) return '—';
|
||||
if (dbm >= -55) return 'Excellent';
|
||||
if (dbm >= -65) return 'Good';
|
||||
if (dbm >= -75) return 'Fair';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
function signalColor(dbm: number | null): string {
|
||||
if (dbm === null) return 'text-gray-400 dark:text-slate-500';
|
||||
if (dbm >= -55) return 'text-green-600 dark:text-green-400';
|
||||
if (dbm >= -65) return 'text-lime-600 dark:text-lime-400';
|
||||
if (dbm >= -75) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
export default function WirelessClientsPage() {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['wireless-clients-page'],
|
||||
queryFn: () =>
|
||||
clientsApi.list({ active: true, client_type: 'wireless', limit: 200 }).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
useSocket({
|
||||
'clients:updated': () => qc.invalidateQueries({ queryKey: ['wireless-clients-page'] }),
|
||||
});
|
||||
|
||||
const clients: Client[] = data?.clients ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Wireless Clients</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Active wireless clients across all access points
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
<RefreshCw className="w-5 h-5 animate-spin inline mr-2" />Loading…
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
<Wifi className="w-6 h-6 mx-auto mb-2 opacity-40" />
|
||||
No active wireless clients
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">IP Address</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">MAC Address</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">SSID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Signal</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Access Point</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
|
||||
{clients.map((client, i) => {
|
||||
const c = client as Client & { device_name?: string; ssid?: string };
|
||||
const name = c.custom_name || c.hostname || c.mac_address;
|
||||
const dbm = c.signal_strength ?? null;
|
||||
return (
|
||||
<tr
|
||||
key={c.mac_address}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors',
|
||||
i % 2 === 1 && 'bg-gray-50/50 dark:bg-slate-800/20'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<button
|
||||
onClick={() => navigate(`/clients/${encodeURIComponent(c.mac_address)}`)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline text-left"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-400">
|
||||
{c.ip_address || <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-400">
|
||||
{c.mac_address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-400">
|
||||
{c.ssid || <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{dbm !== null ? (
|
||||
<span className={clsx('font-medium', signalColor(dbm))}>
|
||||
{dbm} dBm
|
||||
<span className="ml-1.5 font-normal text-xs opacity-75">
|
||||
({signalLabel(dbm)})
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-slate-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-400">
|
||||
{c.device_name || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 text-right">
|
||||
{clients.length} client{clients.length !== 1 ? 's' : ''} · refreshes every 30 s
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, CheckCircle, XCircle, HelpCircle, Users, RefreshCw, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { wirelessApi } from '../services/api';
|
||||
import type { WirelessAP } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' :
|
||||
status === 'offline' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' :
|
||||
'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' ? 'bg-green-500' : status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WirelessPage() {
|
||||
const { data: aps = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['wireless-aps'],
|
||||
queryFn: () => wirelessApi.list().then(r => r.data as WirelessAP[]),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = aps.filter(a => a.status === 'online').length;
|
||||
const offline = aps.filter(a => a.status === 'offline').length;
|
||||
const unknown = aps.filter(a => a.status !== 'online' && a.status !== 'offline').length;
|
||||
const totalClients = aps.reduce((s, a) => s + (Number(a.client_count) || 0), 0);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total APs', value: aps.length, icon: Wifi, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Online', value: online, icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Offline', value: offline, icon: XCircle, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
{ label: 'Unknown', value: unknown, icon: HelpCircle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
|
||||
{ label: 'Wireless Clients', value: totalClients, icon: Users, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-900/20' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-gray-400">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" />Loading wireless APs…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Wireless</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Overview of all managed wireless access points
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{kpis.map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<div key={label} className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx('p-2 rounded-lg', bg)}>
|
||||
<Icon className={clsx('w-4 h-4', color)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{value}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AP table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Access Points</h2>
|
||||
</div>
|
||||
|
||||
{aps.length === 0 ? (
|
||||
<div className="p-10 text-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No wireless APs found — add a device with type <strong>Wireless AP</strong> in Devices.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Model</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">IP Address</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-500 dark:text-slate-400">Radios</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-500 dark:text-slate-400">Active SSIDs</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-500 dark:text-slate-400">Clients</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">ROS Version</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Last Seen</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
|
||||
{aps.map((ap) => (
|
||||
<tr key={ap.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={`/devices/${ap.id}`}
|
||||
className="font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{ap.name}
|
||||
</Link>
|
||||
{ap.rack_name && (
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">
|
||||
{ap.rack_name}{ap.rack_slot ? ` / ${ap.rack_slot}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={ap.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-slate-300">
|
||||
{ap.model || <span className="text-gray-400 dark:text-slate-500">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-400">
|
||||
{ap.ip_address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="font-medium text-gray-800 dark:text-slate-200">
|
||||
{Number(ap.radio_count) || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="font-medium text-gray-800 dark:text-slate-200">
|
||||
{Number(ap.ssid_count) || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
Number(ap.client_count) > 0
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400'
|
||||
: 'bg-gray-50 dark:bg-slate-700/50 text-gray-500 dark:text-slate-400'
|
||||
)}>
|
||||
<Users className="w-3 h-3" />
|
||||
{Number(ap.client_count) || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500 dark:text-slate-400 font-mono">
|
||||
{ap.ros_version || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-400 dark:text-slate-500">
|
||||
{timeAgo(ap.last_seen)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={`/devices/${ap.id}`}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Open device detail"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,590 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type {
|
||||
Device,
|
||||
Interface,
|
||||
Vlan,
|
||||
Client,
|
||||
DeviceEvent,
|
||||
Backup,
|
||||
MetricsSummary,
|
||||
TimeSeriesPoint,
|
||||
TrafficPoint,
|
||||
ResourcePoint,
|
||||
SwitchPort,
|
||||
SystemConfig,
|
||||
IpAddress,
|
||||
PortVlanConfig,
|
||||
PortMonitorData,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<{ token: string; user: import('../types').User }>(
|
||||
'/auth/login',
|
||||
{ username, password }
|
||||
),
|
||||
me: () => api.get('/auth/me'),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
api.put('/auth/password', { currentPassword, newPassword }),
|
||||
};
|
||||
|
||||
// ─── Devices ──────────────────────────────────────────────────────────────────
|
||||
export interface DiscoveredDevice {
|
||||
identity: string;
|
||||
address: string;
|
||||
mac_address: string;
|
||||
platform: string;
|
||||
discovered_at: string;
|
||||
seen_by: string;
|
||||
}
|
||||
|
||||
export const devicesApi = {
|
||||
list: () => api.get<Device[]>('/devices'),
|
||||
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
||||
get: (id: number) => api.get<Device>(`/devices/${id}`),
|
||||
create: (data: Partial<Device> & { api_password: string }) =>
|
||||
api.post<Device>('/devices', data),
|
||||
update: (id: number, data: Partial<Device> & { api_password?: string }) =>
|
||||
api.put<Device>(`/devices/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/devices/${id}`),
|
||||
sync: (id: number) => api.post(`/devices/${id}/sync`, undefined, { timeout: 60_000 }),
|
||||
test: (id: number) => api.post<{ success: boolean; identity?: string; error?: string }>(`/devices/${id}/test`),
|
||||
getInterfaces: (id: number) => api.get<Interface[]>(`/devices/${id}/interfaces`),
|
||||
updateInterface: (id: number, name: string, data: Partial<Interface>) =>
|
||||
api.put(`/devices/${id}/interfaces/${encodeURIComponent(name)}`, data),
|
||||
getVlans: (id: number) => api.get<Vlan[]>(`/devices/${id}/vlans`),
|
||||
getPorts: (id: number) =>
|
||||
api.get<{ ports: SwitchPort[]; vlans: Vlan[] }>(`/devices/${id}/ports`),
|
||||
getRouting: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/routing`),
|
||||
getFirewall: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/firewall`),
|
||||
addFirewallRule: (id: number, data: Record<string, unknown>) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/firewall`, data),
|
||||
updateFirewallRule: (id: number, ruleId: string, data: Record<string, unknown>) =>
|
||||
api.put<Record<string, string>[]>(`/devices/${id}/firewall/${encodeURIComponent(ruleId)}`, data),
|
||||
deleteFirewallRule: (id: number, ruleId: string) =>
|
||||
api.delete(`/devices/${id}/firewall/${encodeURIComponent(ruleId)}`),
|
||||
getResources: (id: number) => api.get<Record<string, string>>(`/devices/${id}/resources`),
|
||||
configurePortVlan: (id: number, name: string, data: PortVlanConfig) =>
|
||||
api.put(`/devices/${id}/ports/${encodeURIComponent(name)}/vlan`, data),
|
||||
getSystemConfig: (id: number) => api.get<SystemConfig>(`/devices/${id}/system-config`),
|
||||
updateSystemConfig: (id: number, data: Partial<{
|
||||
identity: string;
|
||||
ntp_enabled: boolean;
|
||||
ntp_primary: string;
|
||||
ntp_secondary: string;
|
||||
dns_servers: string;
|
||||
dns_allow_remote: boolean;
|
||||
}>) => api.put(`/devices/${id}/system-config`, data),
|
||||
getIpAddresses: (id: number) => api.get<IpAddress[]>(`/devices/${id}/ip-addresses`),
|
||||
addIpAddress: (id: number, data: { address: string; interface: string }) =>
|
||||
api.post<IpAddress[]>(`/devices/${id}/ip-addresses`, data),
|
||||
removeIpAddress: (id: number, addrId: string) =>
|
||||
api.delete(`/devices/${id}/ip-addresses/${encodeURIComponent(addrId)}`),
|
||||
checkUpdate: (id: number) =>
|
||||
api.post<Record<string, string>>(`/devices/${id}/check-update`),
|
||||
getHardware: (id: number) =>
|
||||
api.get<{ health: Record<string, string>[]; disks: Record<string, string>[] }>(`/devices/${id}/hardware`),
|
||||
installUpdate: (id: number) => api.post(`/devices/${id}/install-update`),
|
||||
getClock: (id: number) => api.get<{ date: string; time: string; timezone: string }>(`/devices/${id}/clock`),
|
||||
setClock: (id: number, data: { date?: string; time?: string; timezone?: string }) =>
|
||||
api.put(`/devices/${id}/clock`, data),
|
||||
addRoute: (id: number, data: { dst_address: string; gateway: string; distance?: number; comment?: string }) =>
|
||||
api.post(`/devices/${id}/routing`, data),
|
||||
deleteRoute: (id: number, routeId: string) =>
|
||||
api.delete(`/devices/${id}/routing/${encodeURIComponent(routeId)}`),
|
||||
addVlan: (id: number, data: { bridge: string; vlan_id: number; tagged_ports?: string[]; untagged_ports?: string[] }) =>
|
||||
api.post(`/devices/${id}/vlans`, data),
|
||||
updateVlan: (id: number, vlanDbId: number, data: { tagged_ports: string[]; untagged_ports: string[] }) =>
|
||||
api.put(`/devices/${id}/vlans/${vlanDbId}`, data),
|
||||
deleteVlan: (id: number, vlanDbId: number) => api.delete(`/devices/${id}/vlans/${vlanDbId}`),
|
||||
reboot: (id: number) => api.post<{ message: string }>(`/devices/${id}/reboot`),
|
||||
getPortMonitor: (id: number, name: string) =>
|
||||
api.get<PortMonitorData>(`/devices/${id}/ports/${encodeURIComponent(name)}/monitor`),
|
||||
createBond: (id: number, data: {
|
||||
name: string; mode: string; slaves: string[];
|
||||
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
|
||||
}) => api.post(`/devices/${id}/bonds`, data),
|
||||
updateBond: (id: number, bondName: string, data: {
|
||||
mode: string; slaves: string[];
|
||||
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
|
||||
}) => api.put(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`, data),
|
||||
deleteBond: (id: number, bondName: string) =>
|
||||
api.delete(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`),
|
||||
setBridgeVlanFiltering: (id: number, bridgeName: string, enabled: boolean) =>
|
||||
api.put(`/devices/${id}/bridge/${encodeURIComponent(bridgeName)}/vlan-filtering`, { enabled }),
|
||||
getNat: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/nat`),
|
||||
addNatRule: (id: number, data: Record<string, unknown>) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/nat`, data),
|
||||
updateNatRule: (id: number, ruleId: string, data: Record<string, unknown>) =>
|
||||
api.put<Record<string, string>[]>(`/devices/${id}/nat/${encodeURIComponent(ruleId)}`, data),
|
||||
deleteNatRule: (id: number, ruleId: string) =>
|
||||
api.delete(`/devices/${id}/nat/${encodeURIComponent(ruleId)}`),
|
||||
patchLocation: (id: number, data: {
|
||||
location_address?: string | null;
|
||||
location_lat?: number | null;
|
||||
location_lng?: number | null;
|
||||
rack_name?: string | null;
|
||||
rack_slot?: string | null;
|
||||
notes?: string | null;
|
||||
}) => api.patch<Device>(`/devices/${id}/location`, data),
|
||||
};
|
||||
|
||||
// ─── Clients ──────────────────────────────────────────────────────────────────
|
||||
export interface ClientDetail extends Client {
|
||||
device_type?: string;
|
||||
ssid?: string;
|
||||
vlan_name?: string;
|
||||
upstream_device_id?: number;
|
||||
upstream_interface?: string;
|
||||
upstream_device_name?: string;
|
||||
upstream_device_type?: string;
|
||||
upstream_device_ip?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface PresencePoint { time: string; online: number; }
|
||||
export interface TrafficPoint2 { time: string; tx_bytes: number; rx_bytes: number; }
|
||||
export interface SignalPoint { time: string; signal_strength: number; }
|
||||
|
||||
export const clientsApi = {
|
||||
list: (params?: {
|
||||
deviceId?: number;
|
||||
active?: boolean;
|
||||
search?: string;
|
||||
client_type?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) =>
|
||||
api.get<{ clients: Client[]; total: number }>('/clients', { params }),
|
||||
get: (mac: string) => api.get<ClientDetail>(`/clients/${mac}`),
|
||||
getPresence: (mac: string, range = '24h') =>
|
||||
api.get<PresencePoint[]>(`/clients/${mac}/presence`, { params: { range } }),
|
||||
getTraffic: (mac: string, range = '24h') =>
|
||||
api.get<TrafficPoint2[]>(`/clients/${mac}/traffic`, { params: { range } }),
|
||||
getSignal: (mac: string, range = '24h') =>
|
||||
api.get<SignalPoint[]>(`/clients/${mac}/signal`, { params: { range } }),
|
||||
wol: (mac: string) => api.post<{ success: boolean; message: string }>(`/clients/${mac}/wol`),
|
||||
updateNotes: (mac: string, notes: string) =>
|
||||
api.put<Client>(`/clients/${mac}/notes`, { notes }),
|
||||
updateHostname: (mac: string, hostname: string) =>
|
||||
api.put<Client>(`/clients/${mac}/hostname`, { hostname }),
|
||||
purgeStale: () => api.post<{ message: string; count: number }>('/clients/purge'),
|
||||
};
|
||||
|
||||
// ─── Device Network Tools ─────────────────────────────────────────────────────
|
||||
// Long timeout (120s) since traceroute/ip-scan can run for a while
|
||||
const TOOL_TIMEOUT = 120_000;
|
||||
export const deviceToolsApi = {
|
||||
ping: (id: number, body: { address: string; count?: number; interface?: string }) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/tools/ping`, body, { timeout: TOOL_TIMEOUT }),
|
||||
traceroute: (id: number, body: { address: string }) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/tools/traceroute`, body, { timeout: TOOL_TIMEOUT }),
|
||||
ipScan: (id: number, body: { addressRange: string; interface: string; rdns?: boolean }) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/tools/ip-scan`, body, { timeout: TOOL_TIMEOUT }),
|
||||
wol: (id: number, body: { mac: string; interface: string }) =>
|
||||
api.post<{ success: boolean; message: string }>(`/devices/${id}/tools/wol`, body, { timeout: TOOL_TIMEOUT }),
|
||||
};
|
||||
|
||||
// ─── Events ───────────────────────────────────────────────────────────────────
|
||||
export const eventsApi = {
|
||||
list: (params?: {
|
||||
deviceId?: number;
|
||||
severity?: string;
|
||||
topic?: string;
|
||||
search?: string;
|
||||
since?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) =>
|
||||
api.get<{ events: DeviceEvent[]; total: number; criticalCount: number }>('/events', { params }),
|
||||
clear: (deviceId?: number) => api.delete('/events', { params: deviceId ? { deviceId } : {} }),
|
||||
};
|
||||
|
||||
// ─── Backups ──────────────────────────────────────────────────────────────────
|
||||
export const backupsApi = {
|
||||
list: (deviceId?: number) =>
|
||||
api.get<Backup[]>('/backups', { params: deviceId ? { deviceId } : {} }),
|
||||
create: (deviceId: number, notes?: string) =>
|
||||
api.post<Backup>('/backups', { deviceId, notes }),
|
||||
download: (id: number) =>
|
||||
api.get(`/backups/${id}/download`, { responseType: 'blob' }),
|
||||
restore: (id: number) => api.post(`/backups/${id}/restore`),
|
||||
delete: (id: number) => api.delete(`/backups/${id}`),
|
||||
};
|
||||
|
||||
// ─── Metrics ─────────────────────────────────────────────────────────────────
|
||||
export const metricsApi = {
|
||||
summary: () => api.get<MetricsSummary>('/metrics/summary'),
|
||||
clientsOverTime: (range = '24h') =>
|
||||
api.get<TimeSeriesPoint[]>('/metrics/clients-over-time', { params: { range } }),
|
||||
topClients: (limit = 10) =>
|
||||
api.get<(Client & { total_bytes: number })[]>('/metrics/top-clients', { params: { limit } }),
|
||||
interfaceTraffic: (deviceId: number, iface: string, range = '1h') =>
|
||||
api.get<TrafficPoint[]>(`/metrics/interface/${deviceId}/${encodeURIComponent(iface)}`, {
|
||||
params: { range },
|
||||
}),
|
||||
interfacePackets: (deviceId: number, iface: string, range = '1h') =>
|
||||
api.get<TrafficPoint[]>(`/metrics/interface/${deviceId}/${encodeURIComponent(iface)}/packets`, {
|
||||
params: { range },
|
||||
}),
|
||||
deviceResources: (deviceId: number, range = '24h') =>
|
||||
api.get<ResourcePoint[]>(`/metrics/device/${deviceId}/resources`, { params: { range } }),
|
||||
};
|
||||
|
||||
// ─── Topology ────────────────────────────────────────────────────────────────
|
||||
export const topologyApi = {
|
||||
get: () =>
|
||||
api.get<{
|
||||
devices: Device[];
|
||||
links: import('../types').TopologyLink[];
|
||||
externalNodes: import('../types').ExternalTopologyNode[];
|
||||
}>('/topology'),
|
||||
discover: () => api.post('/topology/discover'),
|
||||
};
|
||||
|
||||
// ─── Search ──────────────────────────────────────────────────────────────────
|
||||
export const searchApi = {
|
||||
search: (q: string) =>
|
||||
api.get<{
|
||||
devices: {
|
||||
id: number; name: string; ip_address: string;
|
||||
model?: string; device_type: string; status: string;
|
||||
}[];
|
||||
clients: {
|
||||
mac_address: string; hostname?: string; ip_address?: string;
|
||||
device_id: number; device_name?: string; active: boolean;
|
||||
}[];
|
||||
events: {
|
||||
id: number; message: string; severity: string;
|
||||
event_time: string; topic?: string; device_name?: string;
|
||||
}[];
|
||||
}>('/search', { params: { q } }),
|
||||
};
|
||||
|
||||
// ─── Routers ──────────────────────────────────────────────────────────────────
|
||||
export const routersApi = {
|
||||
overview: () => api.get<Record<string, unknown>[]>('/devices/routers/overview'),
|
||||
getLldpStatus: () => api.get<{
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; protocol: string | null; error?: string;
|
||||
}[]>('/routers/lldp'),
|
||||
setLldp: (enabled: boolean) => api.put<{
|
||||
applied: number; total: number;
|
||||
results: { id: number; name: string; success: boolean; error?: string }[];
|
||||
}>('/routers/lldp', { enabled }),
|
||||
getSnmpStatus: () => api.get<{
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; community_name?: string; version?: string;
|
||||
auth_protocol?: string; priv_protocol?: string;
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
error?: string;
|
||||
}[]>('/routers/snmp'),
|
||||
setSnmp: (config: {
|
||||
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
auth_protocol?: string; auth_password?: string;
|
||||
priv_protocol?: string; priv_password?: string;
|
||||
}) => api.put<{
|
||||
applied: number; total: number;
|
||||
results: { id: number; name: string; success: boolean; error?: string }[];
|
||||
}>('/routers/snmp', config),
|
||||
};
|
||||
|
||||
// ─── Routing Protocols (per-device) ───────────────────────────────────────────
|
||||
Object.assign(devicesApi, {
|
||||
getOspf: (id: number) => api.get<Record<string, unknown>>(`/devices/${id}/routing/ospf`),
|
||||
addOspfInstance: (id: number, data: Record<string, unknown>) =>
|
||||
api.post(`/devices/${id}/routing/ospf/instance`, data),
|
||||
removeOspfInstance: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/ospf/instance/${encodeURIComponent(itemId)}`),
|
||||
addOspfArea: (id: number, data: Record<string, unknown>) =>
|
||||
api.post(`/devices/${id}/routing/ospf/area`, data),
|
||||
removeOspfArea: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/ospf/area/${encodeURIComponent(itemId)}`),
|
||||
getBgp: (id: number) => api.get<Record<string, unknown>>(`/devices/${id}/routing/bgp`),
|
||||
addBgpConnection: (id: number, data: Record<string, unknown>) =>
|
||||
api.post(`/devices/${id}/routing/bgp/connection`, data),
|
||||
removeBgpConnection: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/bgp/connection/${encodeURIComponent(itemId)}`),
|
||||
getRoutingTables: (id: number) =>
|
||||
api.get<Record<string, string>[]>(`/devices/${id}/routing/tables`),
|
||||
addRoutingTable: (id: number, data: { name: string; fib?: boolean }) =>
|
||||
api.post(`/devices/${id}/routing/tables`, data),
|
||||
removeRoutingTable: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/tables/${encodeURIComponent(itemId)}`),
|
||||
getRouteFilters: (id: number) =>
|
||||
api.get<Record<string, unknown>>(`/devices/${id}/routing/filters`),
|
||||
addFilterRule: (id: number, data: Record<string, unknown>) =>
|
||||
api.post(`/devices/${id}/routing/filters/rule`, data),
|
||||
updateFilterRule: (id: number, itemId: string, data: Record<string, unknown>) =>
|
||||
api.put(`/devices/${id}/routing/filters/rule/${encodeURIComponent(itemId)}`, data),
|
||||
removeFilterRule: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/filters/rule/${encodeURIComponent(itemId)}`),
|
||||
getRouterIds: (id: number) =>
|
||||
api.get<Record<string, string>[]>(`/devices/${id}/routing/router-id`),
|
||||
});
|
||||
|
||||
// ─── Switches ─────────────────────────────────────────────────────────────────
|
||||
export const switchesApi = {
|
||||
list: () => api.get<Record<string, unknown>[]>('/switches'),
|
||||
getLldpStatus: () => api.get<{
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; protocol: string | null; error?: string;
|
||||
}[]>('/switches/lldp'),
|
||||
setLldp: (enabled: boolean) => api.put<{
|
||||
applied: number; total: number;
|
||||
results: { id: number; name: string; success: boolean; error?: string }[];
|
||||
}>('/switches/lldp', { enabled }),
|
||||
getSnmpStatus: () => api.get<{
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; community_name?: string; version?: string;
|
||||
auth_protocol?: string; priv_protocol?: string;
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
error?: string;
|
||||
}[]>('/switches/snmp'),
|
||||
setSnmp: (config: {
|
||||
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
auth_protocol?: string; auth_password?: string;
|
||||
priv_protocol?: string; priv_password?: string;
|
||||
}) => api.put<{
|
||||
applied: number; total: number;
|
||||
results: { id: number; name: string; success: boolean; error?: string }[];
|
||||
}>('/switches/snmp', config),
|
||||
};
|
||||
|
||||
// ─── Alerts ──────────────────────────────────────────────────────────────────
|
||||
export interface AlertRule {
|
||||
event_type: string;
|
||||
enabled: boolean;
|
||||
threshold: number | null;
|
||||
cooldown_min: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlertChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'email' | 'slack' | 'discord' | 'telegram';
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlertHistoryEntry {
|
||||
id: number;
|
||||
event_type: string;
|
||||
device_id: number | null;
|
||||
device_name: string | null;
|
||||
message: string;
|
||||
channels_notified: string[];
|
||||
sent_at: string;
|
||||
}
|
||||
|
||||
export const alertsApi = {
|
||||
getRules: () => api.get<AlertRule[]>('/alerts/rules'),
|
||||
updateRule: (type: string, data: Partial<Pick<AlertRule, 'enabled' | 'threshold' | 'cooldown_min'>>) =>
|
||||
api.put<AlertRule>(`/alerts/rules/${encodeURIComponent(type)}`, data),
|
||||
getChannels: () => api.get<AlertChannel[]>('/alerts/channels'),
|
||||
createChannel: (data: { name: string; type: AlertChannel['type']; enabled?: boolean; config?: Record<string, unknown> }) =>
|
||||
api.post<AlertChannel>('/alerts/channels', data),
|
||||
updateChannel: (id: number, data: Partial<Pick<AlertChannel, 'name' | 'enabled' | 'config'>>) =>
|
||||
api.put<AlertChannel>(`/alerts/channels/${id}`, data),
|
||||
deleteChannel: (id: number) => api.delete(`/alerts/channels/${id}`),
|
||||
testChannel: (id: number) => api.post<{ message: string }>(`/alerts/channels/${id}/test`),
|
||||
getHistory: (limit = 50) => api.get<AlertHistoryEntry[]>('/alerts/history', { params: { limit } }),
|
||||
};
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────────────
|
||||
export const settingsApi = {
|
||||
get: () => api.get<Record<string, unknown>>('/settings'),
|
||||
update: (data: Record<string, unknown>) => api.put('/settings', data),
|
||||
getUsers: () => api.get('/settings/users'),
|
||||
createUser: (data: { username: string; password: string; role?: string }) =>
|
||||
api.post('/settings/users', data),
|
||||
updateUser: (id: number, data: { role?: string; password?: string }) =>
|
||||
api.put(`/settings/users/${id}`, data),
|
||||
deleteUser: (id: number) => api.delete(`/settings/users/${id}`),
|
||||
};
|
||||
|
||||
export interface CertInfo {
|
||||
exists: boolean;
|
||||
subject?: string;
|
||||
subject_full?: string;
|
||||
issuer?: string;
|
||||
issuer_full?: string;
|
||||
serial_number?: string;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
days_remaining?: number;
|
||||
is_self_signed?: boolean;
|
||||
san?: string | null;
|
||||
}
|
||||
|
||||
export const certApi = {
|
||||
get: () => api.get<CertInfo>('/cert'),
|
||||
regenerate: () => api.post<CertInfo & { message: string }>('/cert/regenerate'),
|
||||
upload: (certificate: string, private_key: string) =>
|
||||
api.post<CertInfo & { message: string }>('/cert/upload', { certificate, private_key }),
|
||||
};
|
||||
|
||||
// ─── Wireless ─────────────────────────────────────────────────────────────────
|
||||
export const wirelessApi = {
|
||||
// Section overview
|
||||
list: () => api.get('/wireless'),
|
||||
|
||||
// Multi-AP SSID deployment
|
||||
bulkCreateInterfaces: (data: { apIds: number[] } & Record<string, unknown>) =>
|
||||
api.post('/wireless/ssid/bulk', data),
|
||||
|
||||
// Per-AP: wireless interfaces (SSIDs)
|
||||
getInterfaces: (apId: number) => api.get(`/wireless/${apId}/interfaces`),
|
||||
createInterface: (apId: number, data: Record<string, unknown>) =>
|
||||
api.post(`/wireless/${apId}/interfaces`, data),
|
||||
updateInterface: (apId: number, name: string, data: Record<string, unknown>) =>
|
||||
api.put(`/wireless/${apId}/interfaces/${encodeURIComponent(name)}`, data),
|
||||
deleteInterface: (apId: number, name: string) =>
|
||||
api.delete(`/wireless/${apId}/interfaces/${encodeURIComponent(name)}`),
|
||||
|
||||
// Per-AP: security profiles
|
||||
getSecurityProfiles: (apId: number) => api.get(`/wireless/${apId}/security-profiles`),
|
||||
createSecurityProfile: (apId: number, data: Record<string, unknown>) =>
|
||||
api.post(`/wireless/${apId}/security-profiles`, data),
|
||||
updateSecurityProfile: (apId: number, name: string, data: Record<string, unknown>) =>
|
||||
api.put(`/wireless/${apId}/security-profiles/${encodeURIComponent(name)}`, data),
|
||||
deleteSecurityProfile: (apId: number, name: string) =>
|
||||
api.delete(`/wireless/${apId}/security-profiles/${encodeURIComponent(name)}`),
|
||||
|
||||
// Hardware radio capabilities (wifi package)
|
||||
getRadios: (apId: number) => api.get(`/wireless/${apId}/radios`),
|
||||
|
||||
// Bridge list + port memberships
|
||||
getBridges: (apId: number) => api.get(`/wireless/${apId}/bridges`),
|
||||
|
||||
// Radio monitoring
|
||||
getRegistrationTable: (apId: number) => api.get(`/wireless/${apId}/registration-table`),
|
||||
getMonitor: (apId: number, iface: string) =>
|
||||
api.get(`/wireless/${apId}/monitor/${encodeURIComponent(iface)}`),
|
||||
scan: (apId: number, iface: string) =>
|
||||
api.get(`/wireless/${apId}/scan/${encodeURIComponent(iface)}`),
|
||||
|
||||
// Cached wireless data (for device detail Radios tab)
|
||||
getCachedInterfaces: (deviceId: number) => api.get(`/devices/${deviceId}/wireless`),
|
||||
getMetrics: (deviceId: number, iface?: string, range?: string) =>
|
||||
api.get(`/devices/${deviceId}/wireless/metrics`, { params: { iface, range } }),
|
||||
|
||||
// Spectral scan
|
||||
runSpectralScan: (apId: number, iface: string) =>
|
||||
api.post(`/wireless/${apId}/spectral-scan/${encodeURIComponent(iface)}`),
|
||||
getSpectralHistory: (apId: number, iface: string, limit = 5) =>
|
||||
api.get(`/wireless/${apId}/spectral-history/${encodeURIComponent(iface)}`, { params: { limit } }),
|
||||
|
||||
// AP scan
|
||||
runAPScan: (apId: number) => api.post(`/wireless/${apId}/ap-scan`),
|
||||
getAPScanHistory: (apId: number, limit = 5) =>
|
||||
api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }),
|
||||
};
|
||||
|
||||
// ─── Network Services ─────────────────────────────────────────────────────────
|
||||
|
||||
type NS = Record<string, string>;
|
||||
|
||||
export const networkServicesApi = {
|
||||
// Overview
|
||||
overview: () =>
|
||||
api.get<Record<string, unknown>[]>('/network-services/overview', { timeout: 60_000 }),
|
||||
|
||||
// ── DHCP ──────────────────────────────────────────────────────────────────
|
||||
getDhcp: (deviceId: number) =>
|
||||
api.get<{ ipv4: NS[]; ipv6: NS[]; pools_v4: NS[]; pools_v6: NS[] }>(
|
||||
'/network-services/dhcp', { params: { deviceId } }
|
||||
),
|
||||
addDhcpServer: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
|
||||
api.post<NS[]>('/network-services/dhcp/server', body, { params: { deviceId } }),
|
||||
updateDhcpServer: (deviceId: number, id: string, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
|
||||
api.put<NS[]>(`/network-services/dhcp/server/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
|
||||
deleteDhcpServer: (deviceId: number, id: string, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.delete(`/network-services/dhcp/server/${encodeURIComponent(id)}`, { params: { deviceId, protocol } }),
|
||||
toggleDhcpServer: (deviceId: number, serverId: string, disabled: boolean, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.put('/network-services/dhcp/server', { serverId, disabled, protocol }, { params: { deviceId } }),
|
||||
addDhcpPool: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
|
||||
api.post<NS[]>('/network-services/dhcp/pool', body, { params: { deviceId } }),
|
||||
deleteDhcpPool: (deviceId: number, id: string, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.delete(`/network-services/dhcp/pool/${encodeURIComponent(id)}`, { params: { deviceId, protocol } }),
|
||||
getLeases: (deviceId: number, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.get<NS[]>('/network-services/dhcp/leases', { params: { deviceId, protocol } }),
|
||||
addStaticLease: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
|
||||
api.post('/network-services/dhcp/static-lease', body, { params: { deviceId } }),
|
||||
deleteStaticLease: (deviceId: number, id: string, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.delete(`/network-services/dhcp/static-lease/${encodeURIComponent(id)}`, { params: { deviceId, protocol } }),
|
||||
|
||||
// ── DNS ───────────────────────────────────────────────────────────────────
|
||||
getDns: (deviceId: number) =>
|
||||
api.get<{ settings: NS; statics: NS[] }>('/network-services/dns', { params: { deviceId } }),
|
||||
setDns: (deviceId: number, settings: {
|
||||
servers?: string; allow_remote_requests?: boolean;
|
||||
max_udp_packet_size?: string; cache_size?: string; cache_max_ttl?: string;
|
||||
}) =>
|
||||
api.put<NS>('/network-services/dns', settings, { params: { deviceId } }),
|
||||
flushDns: (deviceId: number) =>
|
||||
api.post('/network-services/dns/flush', {}, { params: { deviceId } }),
|
||||
addDnsStatic: (deviceId: number, body: NS) =>
|
||||
api.post<NS[]>('/network-services/dns/static', body, { params: { deviceId } }),
|
||||
updateDnsStatic: (deviceId: number, id: string, body: NS) =>
|
||||
api.put<NS[]>(`/network-services/dns/static/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
|
||||
deleteDnsStatic: (deviceId: number, id: string) =>
|
||||
api.delete(`/network-services/dns/static/${encodeURIComponent(id)}`, { params: { deviceId } }),
|
||||
|
||||
// ── NTP ───────────────────────────────────────────────────────────────────
|
||||
getNtp: (deviceId: number) =>
|
||||
api.get<{ server: NS; client: NS }>('/network-services/ntp', { params: { deviceId } }),
|
||||
setNtp: (deviceId: number, settings: {
|
||||
server_enabled?: boolean; server_broadcast?: boolean; server_manycast?: boolean;
|
||||
client_enabled?: boolean; client_mode?: string; client_servers?: string;
|
||||
}) =>
|
||||
api.put<{ server: NS; client: NS }>('/network-services/ntp', settings, { params: { deviceId } }),
|
||||
|
||||
// ── WireGuard ─────────────────────────────────────────────────────────────
|
||||
getWireGuard: (deviceId: number) =>
|
||||
api.get<{ interfaces: NS[]; peers: NS[] }>('/network-services/wireguard', { params: { deviceId } }),
|
||||
addWireGuardInterface: (deviceId: number, body: NS) =>
|
||||
api.post<NS[]>('/network-services/wireguard', body, { params: { deviceId } }),
|
||||
updateWireGuardInterface: (deviceId: number, id: string, body: NS) =>
|
||||
api.put<NS[]>(`/network-services/wireguard/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
|
||||
deleteWireGuardInterface: (deviceId: number, id: string) =>
|
||||
api.delete(`/network-services/wireguard/${encodeURIComponent(id)}`, { params: { deviceId } }),
|
||||
toggleWireGuard: (deviceId: number, interfaceId: string, disabled: boolean) =>
|
||||
api.put('/network-services/wireguard/toggle', { interfaceId, disabled }, { params: { deviceId } }),
|
||||
addWireGuardPeer: (deviceId: number, body: NS) =>
|
||||
api.post<NS[]>('/network-services/wireguard/peer', body, { params: { deviceId } }),
|
||||
updateWireGuardPeer: (deviceId: number, id: string, body: NS) =>
|
||||
api.put<NS[]>(`/network-services/wireguard/peer/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
|
||||
deleteWireGuardPeer: (deviceId: number, id: string) =>
|
||||
api.delete(`/network-services/wireguard/peer/${encodeURIComponent(id)}`, { params: { deviceId } }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
setAuth: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
setAuth: (token, user) => set({ token, user, isAuthenticated: true }),
|
||||
logout: () => set({ token: null, user: null, isAuthenticated: false }),
|
||||
}),
|
||||
{
|
||||
name: 'mikrotik-auth',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: 'light',
|
||||
setTheme: (theme) => {
|
||||
set({ theme });
|
||||
applyTheme(theme);
|
||||
},
|
||||
toggleTheme: () => {
|
||||
const next = get().theme === 'light' ? 'dark' : 'light';
|
||||
set({ theme: next });
|
||||
applyTheme(next);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mikrotik-theme',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) applyTheme(state.theme);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function applyTheme(theme: Theme): void {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
export type UserRole = 'admin' | 'operator' | 'viewer';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface IpAddress {
|
||||
'.id': string;
|
||||
address: string;
|
||||
interface: string;
|
||||
network?: string;
|
||||
disabled?: string;
|
||||
invalid?: string;
|
||||
dynamic?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
identity: string;
|
||||
ntp: {
|
||||
enabled?: string;
|
||||
'primary-ntp'?: string;
|
||||
'secondary-ntp'?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
dns: {
|
||||
servers?: string;
|
||||
'allow-remote-requests'?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PortVlanConfig {
|
||||
pvid?: number;
|
||||
tagged_vlans?: number[];
|
||||
untagged_vlans?: number[];
|
||||
}
|
||||
|
||||
export type DeviceStatus = 'online' | 'offline' | 'unknown';
|
||||
export type DeviceType = 'router' | 'switch' | 'wireless_ap' | 'other';
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
api_port: number;
|
||||
api_username: string;
|
||||
ssh_port?: number;
|
||||
ssh_username?: string;
|
||||
model?: string;
|
||||
serial_number?: string;
|
||||
firmware_version?: string;
|
||||
ros_version?: string;
|
||||
latest_ros_version?: string;
|
||||
firmware_update_available?: boolean;
|
||||
device_type: DeviceType;
|
||||
status: DeviceStatus;
|
||||
last_seen?: string;
|
||||
notes?: string;
|
||||
location_address?: string;
|
||||
location_lat?: number;
|
||||
location_lng?: number;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
id: number;
|
||||
device_id: number;
|
||||
name: string;
|
||||
type?: string;
|
||||
mac_address?: string;
|
||||
mtu?: number;
|
||||
running: boolean;
|
||||
disabled: boolean;
|
||||
comment?: string;
|
||||
speed?: string;
|
||||
full_duplex?: boolean;
|
||||
config_json?: Record<string, string>;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Vlan {
|
||||
id: number;
|
||||
device_id: number;
|
||||
vlan_id: number;
|
||||
name?: string;
|
||||
bridge?: string;
|
||||
tagged_ports?: string[];
|
||||
untagged_ports?: string[];
|
||||
}
|
||||
|
||||
export interface BridgeVlanEntry {
|
||||
device_id: number;
|
||||
bridge: string;
|
||||
port: string;
|
||||
pvid?: number;
|
||||
vlan_ids?: string[];
|
||||
tagged: boolean;
|
||||
}
|
||||
|
||||
export interface SwitchPort extends Interface {
|
||||
bridgeInfo?: BridgeVlanEntry | null;
|
||||
}
|
||||
|
||||
export interface PortMonitorData {
|
||||
// Link state
|
||||
status?: string;
|
||||
'auto-negotiation'?: string;
|
||||
rate?: string;
|
||||
'full-duplex'?: string;
|
||||
'tx-flow-control'?: string;
|
||||
'rx-flow-control'?: string;
|
||||
'fec-mode'?: string;
|
||||
// SFP
|
||||
'sfp-module-present'?: string;
|
||||
'sfp-type'?: string;
|
||||
'sfp-connector-type'?: string;
|
||||
'sfp-vendor-name'?: string;
|
||||
'sfp-vendor-part-number'?: string;
|
||||
'sfp-vendor-revision'?: string;
|
||||
'sfp-vendor-serial'?: string;
|
||||
'sfp-manufacturing-date'?: string;
|
||||
'sfp-wavelength'?: string;
|
||||
'sfp-temperature'?: string;
|
||||
'sfp-supply-voltage'?: string;
|
||||
'sfp-tx-bias-current'?: string;
|
||||
'sfp-tx-power'?: string;
|
||||
'sfp-rx-power'?: string;
|
||||
'sfp-link-length-50um'?: string;
|
||||
'sfp-link-length-62um'?: string;
|
||||
'sfp-link-length-copper'?: string;
|
||||
'sfp-link-length-multimode'?: string;
|
||||
'sfp-link-length-singlemode'?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export type EventSeverity = 'info' | 'warning' | 'error' | 'critical';
|
||||
|
||||
export interface DeviceEvent {
|
||||
id: number;
|
||||
device_id?: number;
|
||||
device_name?: string;
|
||||
event_time: string;
|
||||
severity: EventSeverity;
|
||||
topic?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: number;
|
||||
device_id: number;
|
||||
device_name?: string;
|
||||
mac_address: string;
|
||||
custom_name?: string;
|
||||
hostname?: string;
|
||||
vendor?: string;
|
||||
ip_address?: string;
|
||||
interface_name?: string;
|
||||
vlan_id?: number;
|
||||
tx_bytes: number;
|
||||
rx_bytes: number;
|
||||
signal_strength?: number;
|
||||
client_type: 'wired' | 'wireless';
|
||||
active: boolean;
|
||||
last_seen?: string;
|
||||
}
|
||||
|
||||
export interface Backup {
|
||||
id: number;
|
||||
device_id: number;
|
||||
device_name?: string;
|
||||
filename: string;
|
||||
size_bytes?: number;
|
||||
backup_type: 'manual' | 'scheduled';
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TopologyDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
device_type: DeviceType;
|
||||
status: DeviceStatus;
|
||||
ros_version?: string;
|
||||
}
|
||||
|
||||
export interface TopologyLink {
|
||||
id: number;
|
||||
from_device_id?: number;
|
||||
from_interface?: string;
|
||||
to_device_id?: number;
|
||||
to_interface?: string;
|
||||
neighbor_address?: string;
|
||||
neighbor_identity?: string;
|
||||
neighbor_platform?: string;
|
||||
neighbor_mac?: string;
|
||||
stp_role?: string;
|
||||
stp_state?: string;
|
||||
bridge_name?: string;
|
||||
neighbor_caps?: string;
|
||||
link_type?: string;
|
||||
discovered_by?: string;
|
||||
from_device_name?: string;
|
||||
to_device_name?: string;
|
||||
discovered_at: string;
|
||||
}
|
||||
|
||||
export interface ExternalTopologyNode {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
platform: string;
|
||||
mac: string;
|
||||
caps?: string;
|
||||
}
|
||||
|
||||
export interface MetricsSummary {
|
||||
devices: { total: number; online: number; offline: number };
|
||||
clients: { total: number; active: number };
|
||||
alerts: { critical: number; warning: number };
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
time: string;
|
||||
value: number;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export interface TrafficPoint {
|
||||
time: string;
|
||||
rx: number;
|
||||
tx: number;
|
||||
}
|
||||
|
||||
export interface ResourcePoint {
|
||||
time: string;
|
||||
cpu_load?: number;
|
||||
memory_used?: number;
|
||||
memory_total?: number;
|
||||
}
|
||||
|
||||
// ─── Wireless ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WirelessInterface {
|
||||
id: number;
|
||||
device_id: number;
|
||||
name: string;
|
||||
ssid?: string;
|
||||
mode?: string;
|
||||
band?: string;
|
||||
frequency?: number;
|
||||
channel_width?: string;
|
||||
tx_power?: number;
|
||||
tx_power_mode?: string;
|
||||
antenna_gain?: number;
|
||||
country?: string;
|
||||
installation?: string;
|
||||
disabled: boolean;
|
||||
running: boolean;
|
||||
mac_address?: string;
|
||||
security_profile?: string;
|
||||
noise_floor?: number;
|
||||
registered_clients: number;
|
||||
config_json?: Record<string, string>;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface WirelessSecurityProfile {
|
||||
id?: number;
|
||||
'.id'?: string;
|
||||
device_id?: number;
|
||||
name: string;
|
||||
mode: string;
|
||||
authentication_types?: string[];
|
||||
'authentication-types'?: string;
|
||||
unicast_ciphers?: string[];
|
||||
'unicast-ciphers'?: string;
|
||||
group_ciphers?: string[];
|
||||
'group-ciphers'?: string;
|
||||
management_protection?: string;
|
||||
'management-protection'?: string;
|
||||
config_json?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface WirelessAP {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
status: string;
|
||||
last_seen?: string;
|
||||
ros_version?: string;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
radio_count: number;
|
||||
client_count: number;
|
||||
ssid_count: number;
|
||||
}
|
||||
|
||||
export interface WirelessMetricPoint {
|
||||
time: string;
|
||||
interface: string;
|
||||
ssid?: string;
|
||||
registered_clients?: number;
|
||||
noise_floor?: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = 'v0.10.0 Beta';
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
sidebar: {
|
||||
light: '#f1f5f9',
|
||||
dark: '#0f172a',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'slide-in': 'slideIn 0.2s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideIn: {
|
||||
'0%': { transform: 'translateX(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||
charts: ['recharts'],
|
||||
flow: ['@xyflow/react'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
# Stage 1: Build the React frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source and build
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Install openssl for self-signed certificate generation
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
# Copy built frontend
|
||||
COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Remove default nginx config backup
|
||||
RUN rm -f /etc/nginx/conf.d/default.conf.bak
|
||||
|
||||
# Copy and prepare entrypoint script
|
||||
COPY nginx/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 80 443
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
CERT=/certs/server.crt
|
||||
KEY=/certs/server.key
|
||||
RELOAD_SIGNAL=/certs/.reload
|
||||
|
||||
# Ensure certs directory exists (it will be a mounted volume, but just in case)
|
||||
mkdir -p /certs
|
||||
|
||||
# Generate self-signed certificate if none exists
|
||||
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
|
||||
echo "[entrypoint] No certificate found — generating self-signed certificate..."
|
||||
openssl req -x509 -nodes -days 3650 \
|
||||
-newkey rsa:2048 \
|
||||
-keyout "$KEY" \
|
||||
-out "$CERT" \
|
||||
-subj "/CN=Mikrotik Manager/O=Self-Signed/OU=Local/C=US" \
|
||||
-addext "subjectAltName=IP:127.0.0.1,DNS:localhost" \
|
||||
2>/dev/null
|
||||
echo "[entrypoint] Self-signed certificate generated (valid 10 years)."
|
||||
fi
|
||||
|
||||
# Background watcher: reload nginx when backend writes a .reload signal file
|
||||
(while true; do
|
||||
if [ -f "$RELOAD_SIGNAL" ]; then
|
||||
rm -f "$RELOAD_SIGNAL"
|
||||
echo "[entrypoint] Certificate change detected — reloading nginx..."
|
||||
nginx -s reload 2>/dev/null || true
|
||||
fi
|
||||
sleep 5
|
||||
done) &
|
||||
|
||||
echo "[entrypoint] Starting nginx..."
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -0,0 +1,71 @@
|
||||
# HTTP → HTTPS redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /certs/server.crt;
|
||||
ssl_certificate_key /certs/server.key;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
|
||||
|
||||
# Frontend static files
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
# WebSocket proxy (Socket.io)
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# React SPA - serve index.html for all non-API routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user