Initial commit baby!

This commit is contained in:
Rich Teslow
2026-04-13 18:15:41 -07:00
parent 10e6227931
commit 6b0fc9fdf1
99 changed files with 34064 additions and 3 deletions
+18
View File
@@ -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
+2 -2
View File
@@ -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,
+310 -1
View File
@@ -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.
![Version](https://img.shields.io/badge/version-0.10.0_Beta-blue)
![License](https://img.shields.io/badge/license-AGPLv3-blue)
![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white)
---
## 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.
+40
View File
@@ -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"]
+46
View File
@@ -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"
}
}
+53
View File
@@ -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();
}
}
+34
View File
@@ -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 };
+26
View File
@@ -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,
});
}
+337
View File
@@ -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);
});
}
+260
View File
@@ -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);
});
+58
View File
@@ -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();
}
+18
View File
@@ -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 }),
});
}
+198
View File
@@ -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;
+59
View File
@@ -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;
+92
View File
@@ -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;
+156
View File
@@ -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;
+322
View File
@@ -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
+94
View File
@@ -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;
+256
View File
@@ -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;
+399
View File
@@ -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;
+150
View File
@@ -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;
+51
View File
@@ -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;
+115
View File
@@ -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;
+170
View File
@@ -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;
+116
View File
@@ -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;
+704
View File
@@ -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;
+355
View File
@@ -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();
+217
View File
@@ -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 });
});
}
}
+710
View File
@@ -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;
}
}
+44
View File
@@ -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');
}
+108
View File
@@ -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')); });
});
}
+80
View File
@@ -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 };
}
+19
View File
@@ -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"]
}
+119
View File
@@ -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
+14
View File
@@ -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>
+3752
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+7
View File
@@ -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

+73
View File
@@ -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 }}
/>
);
}
+322
View File
@@ -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: '&copy; <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 &amp; 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 &amp; 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 &amp; 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>
);
}
+428
View File
@@ -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>
);
}
+67
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
import { useAuthStore } from '../store/authStore';
export function useCanWrite(): boolean {
const user = useAuthStore((state) => state.user);
return user?.role !== 'viewer';
}
+45
View File
@@ -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]);
}
+108
View File
@@ -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;
}
}
+38
View File
@@ -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>
);
+278
View File
@@ -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>
);
}
+808
View File
@@ -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>
);
}
+399
View File
@@ -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>
);
}
+603
View File
@@ -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: '&copy; <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>&nbsp;<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>
);
}
+328
View File
@@ -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>
);
}
+356
View File
@@ -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>
);
}
+248
View File
@@ -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>
);
}
+176
View File
@@ -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>
);
}
+478
View File
@@ -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>
);
}
+197
View File
@@ -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
+194
View File
@@ -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>
);
}
+523
View File
@@ -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>
);
}
+635
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}
+197
View File
@@ -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
+590
View File
@@ -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;
+31
View File
@@ -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,
}),
}
)
);
+42
View File
@@ -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');
}
}
+314
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = 'v0.10.0 Beta';
+43
View File
@@ -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: [],
};
+25
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+38
View File
@@ -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'],
},
},
},
},
});
+35
View File
@@ -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"]
+35
View File
@@ -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;"
+71
View File
@@ -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;
}
}