From 6b0fc9fdf1e014b1d00228ca24415c79f9270d8e Mon Sep 17 00:00:00 2001 From: Rich Teslow <107219997+2GT-Rich@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:15:41 -0700 Subject: [PATCH] Initial commit baby! --- .env.example | 18 + LICENSE | 4 +- README.md | 311 +- backend/Dockerfile | 40 + backend/package.json | 46 + backend/src/config/database.ts | 53 + backend/src/config/influxdb.ts | 34 + backend/src/config/redis.ts | 26 + backend/src/db/migrate.ts | 337 ++ backend/src/index.ts | 260 ++ backend/src/middleware/auth.ts | 58 + backend/src/middleware/errorHandler.ts | 18 + backend/src/routes/alerts.ts | 198 + backend/src/routes/auth.ts | 59 + backend/src/routes/backups.ts | 92 + backend/src/routes/cert.ts | 156 + backend/src/routes/clients.ts | 322 ++ backend/src/routes/devices.ts | 1486 +++++++ backend/src/routes/events.ts | 94 + backend/src/routes/metrics.ts | 256 ++ backend/src/routes/networkServices.ts | 399 ++ backend/src/routes/routers.ts | 150 + backend/src/routes/search.ts | 51 + backend/src/routes/settings.ts | 115 + backend/src/routes/switches.ts | 170 + backend/src/routes/topology.ts | 116 + backend/src/routes/wireless.ts | 704 ++++ backend/src/services/AlertService.ts | 355 ++ backend/src/services/BackupService.ts | 217 + backend/src/services/PollerService.ts | 710 ++++ .../src/services/mikrotik/DeviceCollector.ts | 2392 +++++++++++ .../src/services/mikrotik/RouterOSClient.ts | 488 +++ backend/src/utils/crypto.ts | 44 + backend/src/utils/oui.ts | 108 + backend/src/utils/serverArp.ts | 80 + backend/tsconfig.json | 19 + docker-compose.yml | 119 + frontend/index.html | 14 + frontend/package-lock.json | 3752 +++++++++++++++++ frontend/package.json | 41 + frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 7 + frontend/src/App.tsx | 73 + frontend/src/components/CircuitBackground.tsx | 292 ++ frontend/src/components/TerminalModal.tsx | 322 ++ .../device-detail/DeviceLocationSection.tsx | 300 ++ .../components/device-detail/FirewallTab.tsx | 826 ++++ .../components/device-detail/GraphsTab.tsx | 189 + .../components/device-detail/HardwareTab.tsx | 479 +++ .../device-detail/InterfacesTab.tsx | 80 + .../components/device-detail/RadiosTab.tsx | 1142 +++++ .../components/device-detail/RoutingTab.tsx | 716 ++++ .../device-detail/SystemConfigTab.tsx | 552 +++ .../src/components/device-detail/ToolsTab.tsx | 445 ++ .../src/components/device-detail/VlansTab.tsx | 409 ++ .../src/components/devices/AddDeviceModal.tsx | 224 + frontend/src/components/layout/AppLayout.tsx | 29 + .../src/components/layout/GlobalSearch.tsx | 308 ++ frontend/src/components/layout/Sidebar.tsx | 428 ++ frontend/src/components/layout/TopBar.tsx | 67 + .../components/ports/SwitchPortDiagram.tsx | 1900 +++++++++ frontend/src/hooks/useCanWrite.ts | 6 + frontend/src/hooks/useSocket.ts | 45 + frontend/src/index.css | 108 + frontend/src/main.tsx | 38 + frontend/src/pages/BackupsPage.tsx | 278 ++ frontend/src/pages/ClientDetailPage.tsx | 808 ++++ frontend/src/pages/ClientsPage.tsx | 399 ++ frontend/src/pages/DashboardPage.tsx | 603 +++ frontend/src/pages/DeviceDetailPage.tsx | 328 ++ frontend/src/pages/DevicesPage.tsx | 356 ++ frontend/src/pages/EventsPage.tsx | 248 ++ frontend/src/pages/LoginPage.tsx | 176 + .../src/pages/NetworkServicesDHCPPage.tsx | 603 +++ frontend/src/pages/NetworkServicesDNSPage.tsx | 413 ++ frontend/src/pages/NetworkServicesNTPPage.tsx | 289 ++ .../src/pages/NetworkServicesOverviewPage.tsx | 201 + .../pages/NetworkServicesWireGuardPage.tsx | 463 ++ frontend/src/pages/RouterSettingsPage.tsx | 478 +++ frontend/src/pages/RoutersPage.tsx | 197 + frontend/src/pages/SettingsPage.tsx | 1333 ++++++ frontend/src/pages/SwitchesOverviewPage.tsx | 194 + frontend/src/pages/SwitchesSettingsPage.tsx | 523 +++ frontend/src/pages/TopologyPage.tsx | 635 +++ frontend/src/pages/WirelessClientsPage.tsx | 143 + frontend/src/pages/WirelessPage.tsx | 197 + frontend/src/pages/WirelessSettingsPage.tsx | 1064 +++++ frontend/src/services/api.ts | 590 +++ frontend/src/store/authStore.ts | 31 + frontend/src/store/themeStore.ts | 42 + frontend/src/types/index.ts | 314 ++ frontend/src/version.ts | 1 + frontend/tailwind.config.js | 43 + frontend/tsconfig.json | 25 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 38 + nginx/Dockerfile | 35 + nginx/entrypoint.sh | 35 + nginx/nginx.conf | 71 + 99 files changed, 34064 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/config/database.ts create mode 100644 backend/src/config/influxdb.ts create mode 100644 backend/src/config/redis.ts create mode 100644 backend/src/db/migrate.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/middleware/errorHandler.ts create mode 100644 backend/src/routes/alerts.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/backups.ts create mode 100644 backend/src/routes/cert.ts create mode 100644 backend/src/routes/clients.ts create mode 100644 backend/src/routes/devices.ts create mode 100644 backend/src/routes/events.ts create mode 100644 backend/src/routes/metrics.ts create mode 100644 backend/src/routes/networkServices.ts create mode 100644 backend/src/routes/routers.ts create mode 100644 backend/src/routes/search.ts create mode 100644 backend/src/routes/settings.ts create mode 100644 backend/src/routes/switches.ts create mode 100644 backend/src/routes/topology.ts create mode 100644 backend/src/routes/wireless.ts create mode 100644 backend/src/services/AlertService.ts create mode 100644 backend/src/services/BackupService.ts create mode 100644 backend/src/services/PollerService.ts create mode 100644 backend/src/services/mikrotik/DeviceCollector.ts create mode 100644 backend/src/services/mikrotik/RouterOSClient.ts create mode 100644 backend/src/utils/crypto.ts create mode 100644 backend/src/utils/oui.ts create mode 100644 backend/src/utils/serverArp.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/CircuitBackground.tsx create mode 100644 frontend/src/components/TerminalModal.tsx create mode 100644 frontend/src/components/device-detail/DeviceLocationSection.tsx create mode 100644 frontend/src/components/device-detail/FirewallTab.tsx create mode 100644 frontend/src/components/device-detail/GraphsTab.tsx create mode 100644 frontend/src/components/device-detail/HardwareTab.tsx create mode 100644 frontend/src/components/device-detail/InterfacesTab.tsx create mode 100644 frontend/src/components/device-detail/RadiosTab.tsx create mode 100644 frontend/src/components/device-detail/RoutingTab.tsx create mode 100644 frontend/src/components/device-detail/SystemConfigTab.tsx create mode 100644 frontend/src/components/device-detail/ToolsTab.tsx create mode 100644 frontend/src/components/device-detail/VlansTab.tsx create mode 100644 frontend/src/components/devices/AddDeviceModal.tsx create mode 100644 frontend/src/components/layout/AppLayout.tsx create mode 100644 frontend/src/components/layout/GlobalSearch.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/layout/TopBar.tsx create mode 100644 frontend/src/components/ports/SwitchPortDiagram.tsx create mode 100644 frontend/src/hooks/useCanWrite.ts create mode 100644 frontend/src/hooks/useSocket.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/BackupsPage.tsx create mode 100644 frontend/src/pages/ClientDetailPage.tsx create mode 100644 frontend/src/pages/ClientsPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/DeviceDetailPage.tsx create mode 100644 frontend/src/pages/DevicesPage.tsx create mode 100644 frontend/src/pages/EventsPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/NetworkServicesDHCPPage.tsx create mode 100644 frontend/src/pages/NetworkServicesDNSPage.tsx create mode 100644 frontend/src/pages/NetworkServicesNTPPage.tsx create mode 100644 frontend/src/pages/NetworkServicesOverviewPage.tsx create mode 100644 frontend/src/pages/NetworkServicesWireGuardPage.tsx create mode 100644 frontend/src/pages/RouterSettingsPage.tsx create mode 100644 frontend/src/pages/RoutersPage.tsx create mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 frontend/src/pages/SwitchesOverviewPage.tsx create mode 100644 frontend/src/pages/SwitchesSettingsPage.tsx create mode 100644 frontend/src/pages/TopologyPage.tsx create mode 100644 frontend/src/pages/WirelessClientsPage.tsx create mode 100644 frontend/src/pages/WirelessPage.tsx create mode 100644 frontend/src/pages/WirelessSettingsPage.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/store/authStore.ts create mode 100644 frontend/src/store/themeStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/version.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 nginx/Dockerfile create mode 100644 nginx/entrypoint.sh create mode 100644 nginx/nginx.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9edba41 --- /dev/null +++ b/.env.example @@ -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 diff --git a/LICENSE b/LICENSE index 0ad25db..be3f7b2 100644 --- a/LICENSE +++ b/LICENSE @@ -633,8 +633,8 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) 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, diff --git a/README.md b/README.md index a170246..d7da2f0 100644 --- a/README.md +++ b/README.md @@ -1 +1,310 @@ -# mikrotik-manager \ No newline at end of file +# 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. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b8e4c2c --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..5c84e1d --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts new file mode 100644 index 0000000..349c31c --- /dev/null +++ b/backend/src/config/database.ts @@ -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>( + text: string, + params?: unknown[] +): Promise { + 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>( + text: string, + params?: unknown[] +): Promise { + const rows = await query(text, params); + return rows[0] ?? null; +} + +export async function transaction( + fn: (client: import('pg').PoolClient) => Promise +): Promise { + 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(); + } +} diff --git a/backend/src/config/influxdb.ts b/backend/src/config/influxdb.ts new file mode 100644 index 0000000..be93194 --- /dev/null +++ b/backend/src/config/influxdb.ts @@ -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 }; diff --git a/backend/src/config/redis.ts b/backend/src/config/redis.ts new file mode 100644 index 0000000..42e8fbb --- /dev/null +++ b/backend/src/config/redis.ts @@ -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, + }); +} diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts new file mode 100644 index 0000000..75c7611 --- /dev/null +++ b/backend/src/db/migrate.ts @@ -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 { + 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); + }); +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..8f0446c --- /dev/null +++ b/backend/src/index.ts @@ -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 { + // 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); +}); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..6c75c96 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -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(); +} diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..90ae20f --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -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 }), + }); +} diff --git a/backend/src/routes/alerts.ts b/backend/src/routes/alerts.ts new file mode 100644 index 0000000..b7d24b8 --- /dev/null +++ b/backend/src/routes/alerts.ts @@ -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 { + 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) => ({ + ...ch, + config: maskConfig(ch.type as string, ch.config as Record), + })); + 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; + 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; 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; + 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; + 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 = { + email: ['smtp_pass'], + slack: [], + discord: [], + telegram: ['bot_token'], +}; + +function maskConfig(type: string, config: Record): Record { + 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, + incoming: Record +): Record { + 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; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..a5140fd --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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; diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts new file mode 100644 index 0000000..9bf65f6 --- /dev/null +++ b/backend/src/routes/backups.ts @@ -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( + `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; diff --git a/backend/src/routes/cert.ts b/backend/src/routes/cert.ts new file mode 100644 index 0000000..6507dc7 --- /dev/null +++ b/backend/src/routes/cert.ts @@ -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; diff --git a/backend/src/routes/clients.ts b/backend/src/routes/clients.ts new file mode 100644 index 0000000..b529be2 --- /dev/null +++ b/backend/src/routes/clients.ts @@ -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>( + `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 = { '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 = { '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(); + 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(); + 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 = { '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((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; diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts new file mode 100644 index 0000000..ef5080e --- /dev/null +++ b/backend/src/routes/devices.ts @@ -0,0 +1,1486 @@ +import { Router, Request, Response } from 'express'; +import { query, queryOne } from '../config/database'; +import { requireAuth, requireWrite } from '../middleware/auth'; +import { encrypt, decrypt } from '../utils/crypto'; +import { RouterOSClient } from '../services/mikrotik/RouterOSClient'; +import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector'; +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/devices +router.get('/', async (_req: Request, res: Response) => { + const devices = await query( + `SELECT id, name, ip_address, api_port, api_username, model, serial_number, + firmware_version, ros_version, latest_ros_version, firmware_update_available, + device_type, status, last_seen, notes, + location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng, + rack_name, rack_slot, created_at + FROM devices ORDER BY name ASC` + ); + res.json(devices); +}); + +// ─── Routers overview (router-type devices with route counts) ───────────────── +router.get('/routers/overview', async (_req: Request, res: Response) => { + const routers = 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 ifaces_up, + COUNT(i.id) FILTER (WHERE i.running = false AND i.disabled = false) AS ifaces_down, + COUNT(i.id) FILTER (WHERE i.disabled = true) AS ifaces_disabled, + COUNT(i.id) AS ifaces_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 = 'router' + GROUP BY d.id + ORDER BY d.name ASC + `); + res.json(routers); +}); + +// GET /api/devices/discovered — unresolved MikroTik neighbors from topology_links +router.get('/discovered', async (_req: Request, res: Response) => { + const rows = await query<{ + neighbor_identity: string | null; + neighbor_address: string | null; + neighbor_mac: string | null; + neighbor_platform: string | null; + discovered_at: string; + seen_by: string; + }>(` + SELECT tl.neighbor_identity, + COALESCE(NULLIF(tl.neighbor_address, ''), c.ip_address) AS neighbor_address, + tl.neighbor_mac, + tl.neighbor_platform, tl.discovered_at, d.name AS seen_by + FROM topology_links tl + JOIN devices d ON d.id = tl.from_device_id + LEFT JOIN clients c ON LOWER(c.mac_address) = LOWER(tl.neighbor_mac) + AND c.ip_address IS NOT NULL AND c.ip_address != '' + WHERE tl.to_device_id IS NULL + AND tl.neighbor_platform ILIKE '%mikrotik%' + AND (tl.neighbor_address IS NULL OR tl.neighbor_address = '' OR tl.neighbor_address NOT LIKE '%:%') + ORDER BY tl.discovered_at DESC + `); + + // Deduplicate by MAC (most reliable), fallback to IP, then identity + const seen = new Map(); + for (const row of rows) { + const key = row.neighbor_mac || row.neighbor_address || row.neighbor_identity || ''; + if (!key) continue; + if (seen.has(key)) { + const existing = seen.get(key)!; + if (!existing.seen_by_list.includes(row.seen_by)) { + existing.seen_by_list.push(row.seen_by); + } + } else { + seen.set(key, { ...row, seen_by_list: [row.seen_by] }); + } + } + + const results = Array.from(seen.values()).map((r) => ({ + identity: r.neighbor_identity || '', + address: r.neighbor_address || '', + mac_address: r.neighbor_mac || '', + platform: r.neighbor_platform || '', + discovered_at: r.discovered_at, + seen_by: r.seen_by_list.join(', '), + })); + + res.json(results); +}); + +// POST /api/devices +router.post('/', requireWrite, async (req: Request, res: Response) => { + const { name, ip_address, api_port = 8728, api_username, api_password, + ssh_port = 22, ssh_username, ssh_password, device_type = 'router', notes } = req.body; + + if (!name || !ip_address || !api_username || !api_password) { + return res.status(400).json({ error: 'name, ip_address, api_username, api_password are required' }); + } + + // Test connection before saving + const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000); + try { + await testClient.connect(); + testClient.disconnect(); + } catch (err) { + return res.status(422).json({ + error: `Cannot connect to device: ${(err as Error).message}`, + }); + } + + const encryptedPass = encrypt(api_password); + const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null; + + const rows = await query<{ id: number }>( + `INSERT INTO devices (name, ip_address, api_port, api_username, api_password_encrypted, + ssh_port, ssh_username, ssh_password_encrypted, device_type, notes, status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'unknown') RETURNING id`, + [name, ip_address, api_port, api_username, encryptedPass, + ssh_port, ssh_username || null, encryptedSshPass, device_type, notes || null] + ); + + const newId = rows[0].id; + + // Trigger full sync in background + if (pollerService) { + await pollerService.scheduleDeviceSync(newId, 'full'); + } + + const device = await queryOne( + `SELECT id, name, ip_address, api_port, api_username, model, serial_number, + firmware_version, ros_version, device_type, status, last_seen, notes, created_at + FROM devices WHERE id = $1`, + [newId] + ); + + return res.status(201).json(device); +}); + +// GET /api/devices/:id +router.get('/:id', async (req: Request, res: Response) => { + const device = await queryOne( + `SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model, + serial_number, firmware_version, ros_version, device_type, status, last_seen, + notes, location_address, + location_lat::float8 AS location_lat, + location_lng::float8 AS location_lng, + rack_name, rack_slot, created_at, updated_at + FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!device) return res.status(404).json({ error: 'Device not found' }); + return res.json(device); +}); + +// PATCH /api/devices/:id/location — save physical location & rack info +router.patch('/:id/location', requireWrite, async (req: Request, res: Response) => { + const { location_address, location_lat, location_lng, rack_name, rack_slot, notes } = req.body; + const existing = await queryOne(`SELECT id FROM devices WHERE id = $1`, [req.params.id]); + if (!existing) return res.status(404).json({ error: 'Device not found' }); + + await query( + `UPDATE devices SET + location_address = $1, + location_lat = $2, + location_lng = $3, + rack_name = $4, + rack_slot = $5, + notes = $6, + updated_at = NOW() + WHERE id = $7`, + [ + location_address ?? null, + location_lat ?? null, + location_lng ?? null, + rack_name ?? null, + rack_slot ?? null, + notes ?? null, + req.params.id, + ] + ); + + const updated = await queryOne( + `SELECT id, name, ip_address, api_port, api_username, ssh_port, ssh_username, model, + serial_number, firmware_version, ros_version, device_type, status, last_seen, + notes, location_address, + location_lat::float8 AS location_lat, + location_lng::float8 AS location_lng, + rack_name, rack_slot, created_at, updated_at + FROM devices WHERE id = $1`, + [req.params.id] + ); + return res.json(updated); +}); + +// PUT /api/devices/:id +router.put('/:id', requireWrite, async (req: Request, res: Response) => { + const { name, api_port, api_username, api_password, ssh_port, ssh_username, + ssh_password, device_type, notes } = req.body; + + const existing = await queryOne<{ id: number; api_password_encrypted: string }>( + `SELECT id, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!existing) return res.status(404).json({ error: 'Device not found' }); + + const encPass = api_password ? encrypt(api_password) : existing.api_password_encrypted; + const encSshPass = ssh_password ? encrypt(ssh_password) : null; + + await query( + `UPDATE devices SET + name=COALESCE($1,name), api_port=COALESCE($2,api_port), + api_username=COALESCE($3,api_username), api_password_encrypted=$4, + ssh_port=COALESCE($5,ssh_port), ssh_username=COALESCE($6,ssh_username), + ssh_password_encrypted=COALESCE($7,ssh_password_encrypted), + device_type=COALESCE($8,device_type), notes=COALESCE($9,notes), + updated_at=NOW() + WHERE id = $10`, + [name, api_port, api_username, encPass, ssh_port, ssh_username, encSshPass, device_type, notes, req.params.id] + ); + + const updated = await queryOne( + `SELECT id, name, ip_address, api_port, api_username, model, serial_number, + firmware_version, ros_version, device_type, status, last_seen, notes + FROM devices WHERE id = $1`, + [req.params.id] + ); + return res.json(updated); +}); + +// DELETE /api/devices/:id +router.delete('/:id', requireWrite, async (req: Request, res: Response) => { + const result = await query(`DELETE FROM devices WHERE id = $1 RETURNING id`, [req.params.id]); + if (!result.length) return res.status(404).json({ error: 'Device not found' }); + return res.json({ message: 'Device deleted' }); +}); + +// POST /api/devices/:id/sync - run a full resync and wait for it to complete +router.post('/:id/sync', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT * FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.collectAll(); + return res.json({ message: 'Sync completed' }); + } catch (err) { + return res.status(500).json({ error: `Sync failed: ${(err as Error).message}` }); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/interfaces +router.get('/:id/interfaces', async (req: Request, res: Response) => { + const ifaces = await query( + `SELECT * FROM interfaces WHERE device_id = $1 ORDER BY name ASC`, + [req.params.id] + ); + return res.json(ifaces); +}); + +// PUT /api/devices/:id/interfaces/:name +router.put('/:id/interfaces/:name', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne<{ id: number; ip_address: string; api_port: number; api_username: string; api_password_encrypted: string }>( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow as unknown as DeviceRow); + try { + await collector.connect(); + const { disabled, comment, mtu, poe_out, fec_mode, tx_flow_control, rx_flow_control, auto_negotiation, speed } = req.body; + if (typeof disabled === 'boolean') { + await collector.setInterfaceEnabled(req.params.name, !disabled); + } + if (comment !== undefined) { + await collector.setInterfaceComment(req.params.name, comment); + } + if (typeof mtu === 'number' && mtu >= 576 && mtu <= 9216) { + await collector.setInterfaceMtu(req.params.name, mtu); + } + if (poe_out && ['auto-on', 'forced-on', 'off'].includes(poe_out)) { + await collector.setPoeOut(req.params.name, poe_out as 'auto-on' | 'forced-on' | 'off'); + } + if (fec_mode && ['clause-74', 'clause-91', 'off'].includes(fec_mode)) { + await collector.setFecMode(req.params.name, fec_mode); + } + if (tx_flow_control !== undefined || rx_flow_control !== undefined) { + const txFc = tx_flow_control ?? 'off'; + const rxFc = rx_flow_control ?? 'off'; + if (['on', 'off', 'auto'].includes(txFc) && ['on', 'off', 'auto'].includes(rxFc)) { + await collector.setFlowControl(req.params.name, txFc, rxFc); + } + } + if (typeof auto_negotiation === 'boolean') { + await collector.setAutoNegotiation(req.params.name, auto_negotiation, speed); + } + await collector.collectInterfaces(); + return res.json({ message: 'Interface updated' }); + } finally { + collector.disconnect(); + } +}); + +// PUT /api/devices/:id/ports/:name/vlan - configure VLAN for a switch port +router.put('/:id/ports/:name/vlan', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const { pvid, tagged_vlans = [], untagged_vlans = [] } = req.body; + if (pvid !== undefined && (typeof pvid !== 'number' || pvid < 1 || pvid > 4094)) { + return res.status(400).json({ error: 'pvid must be 1-4094' }); + } + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.setPortVlanConfig( + req.params.name, + pvid ?? 1, + tagged_vlans as number[], + untagged_vlans as number[] + ); + await collector.collectVlans(); + return res.json({ message: 'VLAN configuration applied' }); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/vlans +router.get('/:id/vlans', async (req: Request, res: Response) => { + const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]); + return res.json(vlans); +}); + +// GET /api/devices/:id/ports/:name/monitor — live ethernet monitor + SFP DDM +router.get('/:id/ports/:name/monitor', async (req: Request, res: Response) => { + const deviceRow = await queryOne<{ id: number; ip_address: string; api_port: number; api_username: string; api_password_encrypted: string }>( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow as unknown as DeviceRow); + try { + await collector.connect(); + const monitor = await collector.getPortMonitor(req.params.name); + return res.json(monitor); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/ports (switch port layout with VLAN info) +router.get('/:id/ports', async (req: Request, res: Response) => { + const [ifaces, bridgePorts, vlans] = await Promise.all([ + query(`SELECT * FROM interfaces WHERE device_id = $1 ORDER BY name`, [req.params.id]), + query(`SELECT * FROM bridge_vlan_entries WHERE device_id = $1`, [req.params.id]), + query(`SELECT * FROM vlans WHERE device_id = $1`, [req.params.id]), + ]); + + // Enrich interface data with VLAN info + const bridgePortMap = new Map(bridgePorts.map((bp: Record) => [bp['port'], bp])); + + const ports = ifaces + .filter((i: Record) => + String(i['type'] || '').match(/^(ether|sfp|combo|bridge|bond)/i) || + String(i['name'] || '').match(/^(ether|sfp|combo|bridge|bond|lag)/i) + ) + .map((i: Record) => ({ + ...i, + bridgeInfo: bridgePortMap.get(i['name']) || null, + })); + + return res.json({ ports, vlans }); +}); + +// GET /api/devices/:id/routing +router.get('/:id/routing', async (req: Request, res: Response) => { + const deviceRow = await queryOne<{ id: number; ip_address: string; api_port: number; api_username: string; api_password_encrypted: string }>( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow as any); + try { + await collector.connect(); + const routes = await collector.getRoutingTable(); + return res.json(routes); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/firewall +router.get('/:id/firewall', async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + return res.json(await collector.getFirewallRules()); + } finally { + collector.disconnect(); + } +}); + +const FW_FIELD_MAP: Record = { + chain: 'chain', action: 'action', protocol: 'protocol', comment: 'comment', disabled: 'disabled', + src_address: 'src-address', dst_address: 'dst-address', + src_port: 'src-port', dst_port: 'dst-port', + in_interface: 'in-interface', out_interface: 'out-interface', + connection_state: 'connection-state', jump_target: 'jump-target', + log: 'log', log_prefix: 'log-prefix', + src_address_list: 'src-address-list', dst_address_list: 'dst-address-list', +}; + +function bodyToRosParams(body: Record): Record { + const params: Record = {}; + for (const [jsKey, rosKey] of Object.entries(FW_FIELD_MAP)) { + const val = body[jsKey]; + if (val !== undefined && val !== null && val !== '') { + params[rosKey] = String(val); + } + } + return params; +} + +// POST /api/devices/:id/firewall +router.post('/:id/firewall', requireWrite, async (req: Request, res: Response) => { + const { chain, action } = req.body; + if (!chain || !action) return res.status(400).json({ error: 'chain and action are required' }); + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addFirewallRule(bodyToRosParams(req.body)); + return res.status(201).json(await collector.getFirewallRules()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// PUT /api/devices/:id/firewall/:ruleId +router.put('/:id/firewall/:ruleId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.updateFirewallRule(req.params.ruleId, bodyToRosParams(req.body)); + return res.json(await collector.getFirewallRules()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// DELETE /api/devices/:id/firewall/:ruleId +router.delete('/:id/firewall/:ruleId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.deleteFirewallRule(req.params.ruleId); + return res.json({ message: 'Rule deleted' }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// ─── NAT Rules ──────────────────────────────────────────────────────────────── +const NAT_FIELD_MAP: Record = { + chain: 'chain', action: 'action', protocol: 'protocol', comment: 'comment', disabled: 'disabled', + src_address: 'src-address', dst_address: 'dst-address', + src_port: 'src-port', dst_port: 'dst-port', + in_interface: 'in-interface', out_interface: 'out-interface', + to_addresses: 'to-addresses', to_ports: 'to-ports', + log: 'log', log_prefix: 'log-prefix', +}; + +function natBodyToRosParams(body: Record): Record { + const params: Record = {}; + for (const [jsKey, rosKey] of Object.entries(NAT_FIELD_MAP)) { + const val = body[jsKey]; + if (val !== undefined && val !== null && val !== '') { + params[rosKey] = String(val); + } + } + return params; +} + +// GET /api/devices/:id/nat +router.get('/:id/nat', async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + return res.json(await collector.getNatRules()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// POST /api/devices/:id/nat +router.post('/:id/nat', requireWrite, async (req: Request, res: Response) => { + const { chain, action } = req.body; + if (!chain || !action) return res.status(400).json({ error: 'chain and action are required' }); + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addNatRule(natBodyToRosParams(req.body)); + return res.status(201).json(await collector.getNatRules()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// PUT /api/devices/:id/nat/:ruleId +router.put('/:id/nat/:ruleId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.updateNatRule(req.params.ruleId, natBodyToRosParams(req.body)); + return res.json(await collector.getNatRules()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// DELETE /api/devices/:id/nat/:ruleId +router.delete('/:id/nat/:ruleId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.deleteNatRule(req.params.ruleId); + return res.json({ message: 'NAT rule deleted' }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/resources (live resource usage) +router.get('/:id/resources', async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + const resource = await collector.getSystemResource(); + return res.json(resource); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/system-config +router.get('/:id/system-config', async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + const config = await collector.getSystemConfig(); + return res.json(config); + } finally { + collector.disconnect(); + } +}); + +// PUT /api/devices/:id/system-config +router.put('/:id/system-config', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const { identity, ntp_enabled, ntp_primary, ntp_secondary, dns_servers, dns_allow_remote } = req.body; + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + if (identity !== undefined) { + await collector.setSystemIdentity(identity); + } + if (ntp_primary !== undefined) { + await collector.setNtpConfig( + ntp_enabled !== false, + ntp_primary || '', + ntp_secondary || '' + ); + } + if (dns_servers !== undefined) { + await collector.setDnsConfig(dns_servers, dns_allow_remote === true); + } + return res.json({ message: 'System configuration updated' }); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/ip-addresses +router.get('/:id/ip-addresses', async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + const addresses = await collector.getIpAddresses(); + return res.json(addresses); + } finally { + collector.disconnect(); + } +}); + +// POST /api/devices/:id/ip-addresses +router.post('/:id/ip-addresses', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const { address, interface: iface } = req.body; + if (!address || !iface) { + return res.status(400).json({ error: 'address and interface are required' }); + } + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addIpAddress(address, iface); + const addresses = await collector.getIpAddresses(); + return res.status(201).json(addresses); + } finally { + collector.disconnect(); + } +}); + +// DELETE /api/devices/:id/ip-addresses/:addrId +router.delete('/:id/ip-addresses/:addrId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.removeIpAddress(req.params.addrId); + return res.json({ message: 'IP address removed' }); + } finally { + collector.disconnect(); + } +}); + +// POST /api/devices/:id/check-update +router.post('/:id/check-update', async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + const updateInfo = await collector.checkForUpdates(); + + // Persist results so the UI reflects the latest check immediately + 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); + + await query( + `UPDATE devices SET firmware_update_available = $1, latest_ros_version = $2, updated_at = NOW() WHERE id = $3`, + [hasUpdate, latestVersion || null, deviceRow.id] + ); + + return res.json(updateInfo); + } finally { + collector.disconnect(); + } +}); + +// POST /api/devices/:id/install-update +router.post('/:id/install-update', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.installUpdate(); + // Clear the update flag — device is rebooting with new firmware + await query( + `UPDATE devices SET firmware_update_available = FALSE, updated_at = NOW() WHERE id = $1`, + [deviceRow.id] + ); + return res.json({ message: 'Update installation initiated. Device will reboot.' }); + } finally { + collector.disconnect(); + } +}); + +// POST /api/devices/:id/reboot +router.post('/:id/reboot', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.reboot(); + return res.json({ message: 'Reboot command sent. Device will restart shortly.' }); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/clock +router.get('/:id/clock', async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + const clock = await collector.getClockConfig(); + return res.json(clock); + } finally { + collector.disconnect(); + } +}); + +// PUT /api/devices/:id/clock +router.put('/:id/clock', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.setClockConfig(req.body); + return res.json({ message: 'Clock updated' }); + } finally { + collector.disconnect(); + } +}); + +// POST /api/devices/:id/routing (add static route) +router.post('/:id/routing', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const { dst_address, gateway, distance, comment } = req.body; + if (!dst_address || !gateway) { + return res.status(400).json({ error: 'dst_address and gateway are required' }); + } + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addRoute(dst_address, gateway, distance, comment); + return res.status(201).json({ message: 'Route added' }); + } finally { + collector.disconnect(); + } +}); + +// DELETE /api/devices/:id/routing/:routeId +router.delete('/:id/routing/:routeId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.removeRoute(req.params.routeId); + return res.json({ message: 'Route removed' }); + } finally { + collector.disconnect(); + } +}); + +// ─── OSPF ───────────────────────────────────────────────────────────────────── +router.get('/:id/routing/ospf', async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + return res.json(await collector.getOspfData()); + } finally { collector.disconnect(); } +}); + +router.post('/:id/routing/ospf/instance', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addOspfInstance(req.body); + return res.status(201).json(await collector.getOspfData()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +router.delete('/:id/routing/ospf/instance/:itemId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.removeOspfInstance(decodeURIComponent(req.params.itemId)); + return res.json({ success: true }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +router.post('/:id/routing/ospf/area', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addOspfArea(req.body); + return res.status(201).json(await collector.getOspfData()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +router.delete('/:id/routing/ospf/area/:itemId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.removeOspfArea(decodeURIComponent(req.params.itemId)); + return res.json({ success: true }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +// ─── BGP ────────────────────────────────────────────────────────────────────── +router.get('/:id/routing/bgp', async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + return res.json(await collector.getBgpData()); + } finally { collector.disconnect(); } +}); + +router.post('/:id/routing/bgp/connection', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addBgpConnection(req.body); + return res.status(201).json(await collector.getBgpData()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +router.delete('/:id/routing/bgp/connection/:itemId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.removeBgpConnection(decodeURIComponent(req.params.itemId)); + return res.json({ success: true }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +// ─── Routing Tables ─────────────────────────────────────────────────────────── +router.get('/:id/routing/tables', async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + return res.json(await collector.getRoutingTablesData()); + } finally { collector.disconnect(); } +}); + +router.post('/:id/routing/tables', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addRoutingTable(req.body); + return res.status(201).json(await collector.getRoutingTablesData()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +router.delete('/:id/routing/tables/:itemId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.removeRoutingTable(decodeURIComponent(req.params.itemId)); + return res.json({ success: true }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +// ─── Route Filters ──────────────────────────────────────────────────────────── +router.get('/:id/routing/filters', async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + return res.json(await collector.getRouteFiltersData()); + } finally { collector.disconnect(); } +}); + +router.post('/:id/routing/filters/rule', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addFilterRule(req.body); + return res.status(201).json(await collector.getRouteFiltersData()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +router.put('/:id/routing/filters/rule/:itemId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.updateFilterRule(decodeURIComponent(req.params.itemId), req.body); + return res.json(await collector.getRouteFiltersData()); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +router.delete('/:id/routing/filters/rule/:itemId', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.removeFilterRule(decodeURIComponent(req.params.itemId)); + return res.json({ success: true }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { collector.disconnect(); } +}); + +// ─── Router IDs ─────────────────────────────────────────────────────────────── +router.get('/:id/routing/router-id', async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + return res.json(await collector.getRouterIds()); + } finally { collector.disconnect(); } +}); + +// POST /api/devices/:id/vlans (add bridge VLAN) +router.post('/:id/vlans', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const { bridge, vlan_id, tagged_ports = [], untagged_ports = [] } = req.body; + if (!bridge || !vlan_id || vlan_id < 1 || vlan_id > 4094) { + return res.status(400).json({ error: 'bridge and vlan_id (1-4094) are required' }); + } + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.addBridgeVlan(bridge, vlan_id, tagged_ports, untagged_ports); + await collector.collectVlans(); + const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]); + return res.status(201).json(vlans); + } finally { + collector.disconnect(); + } +}); + +// PUT /api/devices/:id/vlans/:vlanDbId (update tagged/untagged ports) +router.put('/:id/vlans/:vlanDbId', requireWrite, async (req: Request, res: Response) => { + const vlan = await queryOne<{ id: number; vlan_id: number; bridge: string }>( + `SELECT id, vlan_id, bridge FROM vlans WHERE id = $1 AND device_id = $2`, + [req.params.vlanDbId, req.params.id] + ); + if (!vlan) return res.status(404).json({ error: 'VLAN not found' }); + + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const { tagged_ports = [], untagged_ports = [] } = req.body; + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.updateBridgeVlan(vlan.bridge, vlan.vlan_id, tagged_ports, untagged_ports); + await collector.collectVlans(); + const vlans = await query(`SELECT * FROM vlans WHERE device_id = $1 ORDER BY vlan_id ASC`, [req.params.id]); + return res.json(vlans); + } finally { + collector.disconnect(); + } +}); + +// DELETE /api/devices/:id/vlans/:vlanDbId +router.delete('/:id/vlans/:vlanDbId', requireWrite, async (req: Request, res: Response) => { + const vlan = await queryOne<{ vlan_id: number; bridge: string }>( + `SELECT vlan_id, bridge FROM vlans WHERE id = $1 AND device_id = $2`, + [req.params.vlanDbId, req.params.id] + ); + if (!vlan) return res.status(404).json({ error: 'VLAN not found' }); + + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.removeBridgeVlan(vlan.bridge, vlan.vlan_id); + await query(`DELETE FROM vlans WHERE id = $1`, [req.params.vlanDbId]); + return res.json({ message: 'VLAN removed' }); + } finally { + collector.disconnect(); + } +}); + +// ─── Bond (LAG / LACP) routes ──────────────────────────────────────────────── + +// POST /api/devices/:id/bonds +router.post('/:id/bonds', requireWrite, async (req: Request, res: Response) => { + const { name, mode, slaves, lacp_rate, transmit_hash_policy, mtu, min_links } = req.body; + if (!name || !mode || !Array.isArray(slaves) || slaves.length < 2) { + return res.status(400).json({ error: 'name, mode, and at least 2 slaves are required' }); + } + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.createBond(name, slaves, mode, { + lacpRate: lacp_rate, hashPolicy: transmit_hash_policy, mtu, minLinks: min_links, + }); + await collector.collectInterfaces(); + const ports = await query(`SELECT * FROM interfaces WHERE device_id = $1 ORDER BY name`, [req.params.id]); + return res.status(201).json(ports.find((p: Record) => p['name'] === name) ?? { message: 'Bond created' }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// PUT /api/devices/:id/bonds/:bondName +router.put('/:id/bonds/:bondName', requireWrite, async (req: Request, res: Response) => { + const { mode, slaves, lacp_rate, transmit_hash_policy, mtu, min_links } = req.body; + if (!mode || !Array.isArray(slaves) || slaves.length < 1) { + return res.status(400).json({ error: 'mode and slaves are required' }); + } + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.updateBond(req.params.bondName, slaves, mode, { + lacpRate: lacp_rate, hashPolicy: transmit_hash_policy, mtu, minLinks: min_links, + }); + await collector.collectInterfaces(); + return res.json({ message: 'Bond updated' }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// DELETE /api/devices/:id/bonds/:bondName +router.delete('/:id/bonds/:bondName', requireWrite, async (req: Request, res: Response) => { + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.deleteBond(req.params.bondName); + await query(`DELETE FROM interfaces WHERE device_id = $1 AND name = $2`, [req.params.id, req.params.bondName]); + await collector.collectInterfaces(); + return res.json({ message: 'Bond deleted' }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// PUT /api/devices/:id/bridge/:bridgeName/vlan-filtering +router.put('/:id/bridge/:bridgeName/vlan-filtering', requireWrite, async (req: Request, res: Response) => { + const { enabled } = req.body as { enabled: boolean }; + const deviceRow = await queryOne(`SELECT * FROM devices WHERE id = $1`, [req.params.id]); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + await collector.setBridgeVlanFiltering(req.params.bridgeName, Boolean(enabled)); + await collector.collectInterfaces(); + return res.json({ success: true, vlan_filtering: enabled }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/hardware (live health: temps, voltages, fans, PSU) +router.get('/:id/hardware', async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT id, ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const collector = new DeviceCollector(deviceRow); + try { + await collector.connect(); + const health = await collector.getHardware(); + return res.json(health); + } finally { + collector.disconnect(); + } +}); + +// GET /api/devices/:id/wireless — cached wireless interfaces from postgres (for Radios tab) +router.get('/:id/wireless', async (req: Request, res: Response) => { + const rows = await query( + `SELECT * FROM wireless_interfaces WHERE device_id = $1 ORDER BY name ASC`, + [req.params.id] + ); + return res.json(rows); +}); + +// GET /api/devices/:id/wireless/metrics — InfluxDB wireless_stats time series +router.get('/:id/wireless/metrics', async (req: Request, res: Response) => { + const { iface, range = '6h' } = req.query as { iface?: string; range?: string }; + + const ranges: Record = { + '1h': '1h', '3h': '3h', '6h': '6h', '12h': '12h', '24h': '24h', '7d': '7d', + }; + const fluxRange = ranges[range] || '6h'; + + const { getQueryApi } = await import('../config/influxdb'); + const queryApi = getQueryApi(); + + const org = process.env.INFLUXDB_ORG || 'mikrotik'; + const bucket = process.env.INFLUXDB_BUCKET || 'mikrotik'; + + const ifaceFilter = iface + ? `|> filter(fn: (r) => r["interface"] == "${iface}")` + : ''; + + const flux = ` + from(bucket: "${bucket}") + |> range(start: -${fluxRange}) + |> filter(fn: (r) => r["_measurement"] == "wireless_stats") + |> filter(fn: (r) => r["device_id"] == "${req.params.id}") + ${ifaceFilter} + |> filter(fn: (r) => r["_field"] == "registered_clients" or r["_field"] == "noise_floor") + |> aggregateWindow(every: 5m, fn: mean, createEmpty: false) + |> pivot(rowKey:["_time","interface","ssid"], columnKey: ["_field"], valueColumn: "_value") + |> sort(columns: ["_time"]) + `; + + try { + const rows: { time: string; interface: string; ssid?: string; registered_clients?: number; noise_floor?: number }[] = []; + await new Promise((resolve, reject) => { + queryApi.queryRows(flux, { + next(row, tableMeta) { + const obj = tableMeta.toObject(row) as Record; + rows.push({ + time: String(obj['_time'] || obj['time'] || ''), + interface: String(obj['interface'] || ''), + ssid: obj['ssid'] ? String(obj['ssid']) : undefined, + registered_clients: obj['registered_clients'] != null ? Number(obj['registered_clients']) : undefined, + noise_floor: obj['noise_floor'] != null ? Number(obj['noise_floor']) : undefined, + }); + }, + error: reject, + complete: resolve, + }); + }); + return res.json(rows); + } catch (err) { + console.error('Wireless metrics InfluxDB error:', err); + return res.json([]); + } +}); + +// POST /api/devices/:id/test +router.post('/:id/test', async (req: Request, res: Response) => { + const deviceRow = await queryOne( + `SELECT ip_address, api_port, api_username, api_password_encrypted FROM devices WHERE id = $1`, + [req.params.id] + ); + if (!deviceRow) return res.status(404).json({ error: 'Device not found' }); + + const client = new RouterOSClient( + deviceRow.ip_address, deviceRow.api_port, + deviceRow.api_username, decrypt(deviceRow.api_password_encrypted), 8000 + ); + try { + await client.connect(); + const identity = await client.execute('/system/identity/print'); + client.disconnect(); + return res.json({ success: true, identity: identity[0]?.['name'] }); + } catch (err) { + client.disconnect(); + return res.status(422).json({ success: false, error: (err as Error).message }); + } +}); + +// ─── Network Tools ───────────────────────────────────────────────────────── +// Tools use a long read-timeout (120s) since ping/traceroute/ip-scan can take time. + +async function getToolDevice(id: string) { + return queryOne(`SELECT * FROM devices WHERE id = $1`, [id]); +} + +function makeToolClient(device: DeviceRow): RouterOSClient { + return new RouterOSClient( + device.ip_address, device.api_port, device.api_username, + decrypt(device.api_password_encrypted), + 15_000, // connect timeout + 120_000 // read timeout — traceroute/ip-scan can take a while + ); +} + +// POST /api/devices/:id/tools/ping +router.post('/:id/tools/ping', requireWrite, async (req: Request, res: Response) => { + const { address, count, interface: iface } = req.body as { address?: string; count?: number; interface?: string }; + if (!address) return res.status(400).json({ error: 'address is required' }); + + const device = await getToolDevice(req.params.id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + + const client = makeToolClient(device); + try { + await client.connect(); + const params: Record = { + address, + count: String(Math.min(Math.max(1, Number(count) || 4), 20)), + }; + if (iface) params['interface'] = iface; + const results = await client.execute('/tool/ping', params); + return res.json(results); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + client.disconnect(); + } +}); + +// POST /api/devices/:id/tools/traceroute +router.post('/:id/tools/traceroute', requireWrite, async (req: Request, res: Response) => { + const { address } = req.body as { address?: string }; + if (!address) return res.status(400).json({ error: 'address is required' }); + + const device = await getToolDevice(req.params.id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + + const client = makeToolClient(device); + try { + await client.connect(); + const raw = await client.execute('/tool/traceroute', { address, count: '3', timeout: '1' }); + + // RouterOS sends multiple !re updates per hop as probes return. + // Deduplicate by .id, keeping the last (most complete) row per hop. + const byId = new Map>(); + for (const row of raw) { + if (row['id']) byId.set(row['id'], row); + } + const results = Array.from(byId.values()).sort( + (a, b) => Number(a['id'] || 0) - Number(b['id'] || 0) + ); + return res.json(results); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + client.disconnect(); + } +}); + +// POST /api/devices/:id/tools/ip-scan +router.post('/:id/tools/ip-scan', requireWrite, async (req: Request, res: Response) => { + const { addressRange, interface: iface, rdns } = req.body as { + addressRange?: string; + interface?: string; + rdns?: boolean; + }; + if (!addressRange) return res.status(400).json({ error: 'addressRange is required' }); + if (!iface) return res.status(400).json({ error: 'interface is required' }); + + const device = await getToolDevice(req.params.id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + + const client = makeToolClient(device); + try { + await client.connect(); + // /tool/ip-scan is a generator — it never sends !done on its own. + // Use executeStreaming to collect results for up to 30s then cancel. + const raw = await client.executeStreaming('/tool/ip-scan', { + 'address-range': addressRange, + interface: iface, + }, 30_000); + + // ip-scan re-emits the same host on every rescan cycle — deduplicate by address, + // keeping the last-seen entry (RouterOS updates fields like status over time). + const seen = new Map>(); + for (const entry of raw) { + const key = entry['address'] ?? JSON.stringify(entry); + seen.set(key, entry); + } + const results = Array.from(seen.values()); + + if (!rdns) return res.json(results); + + // Perform reverse DNS lookups concurrently on all discovered IPs. + const { reverse } = await import('dns/promises'); + const enriched = await Promise.all( + results.map(async (r) => { + const ip = r['address']; + if (!ip) return { ...r, hostname: '' }; + try { + const names = await reverse(ip); + return { ...r, hostname: names[0] ?? '' }; + } catch { + return { ...r, hostname: '' }; + } + }) + ); + return res.json(enriched); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + client.disconnect(); + } +}); + +// POST /api/devices/:id/tools/wol +router.post('/:id/tools/wol', requireWrite, async (req: Request, res: Response) => { + const { mac, interface: iface } = req.body as { mac?: string; interface?: string }; + if (!mac) return res.status(400).json({ error: 'mac is required' }); + if (!iface) return res.status(400).json({ error: 'interface is required' }); + + // Basic MAC validation + if (!/^([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}$/.test(mac)) { + return res.status(400).json({ error: 'Invalid MAC address format' }); + } + + const device = await getToolDevice(req.params.id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + + const client = makeToolClient(device); + try { + await client.connect(); + await client.execute('/tool/wol', { mac, interface: iface }); + return res.json({ success: true, message: `WoL magic packet sent to ${mac} on ${iface}` }); + } catch (err) { + return res.status(500).json({ error: (err as Error).message }); + } finally { + client.disconnect(); + } +}); + +export default router; diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts new file mode 100644 index 0000000..d964aee --- /dev/null +++ b/backend/src/routes/events.ts @@ -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(); + 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; diff --git a/backend/src/routes/metrics.ts b/backend/src/routes/metrics.ts new file mode 100644 index 0000000..0733c26 --- /dev/null +++ b/backend/src/routes/metrics.ts @@ -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[] = []; + 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 = {}; + 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[] = []; + 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 = {}; + 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[] = []; + 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> = {}; + 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; diff --git a/backend/src/routes/networkServices.ts b/backend/src/routes/networkServices.ts new file mode 100644 index 0000000..5c1ec89 --- /dev/null +++ b/backend/src/routes/networkServices.ts @@ -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( + deviceId: number, + res: Response, + fn: (collector: DeviceCollector, device: DeviceRow) => Promise +): Promise { + const rows = await query(`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(`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; diff --git a/backend/src/routes/routers.ts b/backend/src/routes/routers.ts new file mode 100644 index 0000000..5786a75 --- /dev/null +++ b/backend/src/routes/routers.ts @@ -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( + `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( + `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( + `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( + `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; diff --git a/backend/src/routes/search.ts b/backend/src/routes/search.ts new file mode 100644 index 0000000..117abd1 --- /dev/null +++ b/backend/src/routes/search.ts @@ -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= +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; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 0000000..7722f2a --- /dev/null +++ b/backend/src/routes/settings.ts @@ -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 = {}; + 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; + 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; diff --git a/backend/src/routes/switches.ts b/backend/src/routes/switches.ts new file mode 100644 index 0000000..d69f001 --- /dev/null +++ b/backend/src/routes/switches.ts @@ -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( + `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( + `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( + `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( + `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; diff --git a/backend/src/routes/topology.ts b/backend/src/routes/topology.ts new file mode 100644 index 0000000..b759f7f --- /dev/null +++ b/backend/src/routes/topology.ts @@ -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 = { 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( + `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(); + 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(); + + 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; diff --git a/backend/src/routes/wireless.ts b/backend/src/routes/wireless.ts new file mode 100644 index 0000000..09ff4cd --- /dev/null +++ b/backend/src/routes/wireless.ts @@ -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 { + const rows = await query( + `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; + + 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( + `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 = { + name: autoName, + ssid: String(ssidBody.ssid), + mode: resolvedMode, + disabled: ssidBody.disabled ? 'yes' : 'no', + }; + const fieldMap: Record = { + 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; + + 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 = { + 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; + + const params: Record = {}; + const map: Record = { + 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; + + if (!name) return res.status(400).json({ error: '"name" is required' }); + + const params: Record = { + 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; + + const params: Record = {}; + 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[] +): { freq: number; magn: number; peak: number }[] { + const map = new Map(); + 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[] }[] +): APNetworkEntry[] { + const byKey = new Map(); + + 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[] }[] = []; + 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; diff --git a/backend/src/services/AlertService.ts b/backend/src/services/AlertService.ts new file mode 100644 index 0000000..0430a45 --- /dev/null +++ b/backend/src/services/AlertService.ts @@ -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; +} + +// In-memory cooldown map (keyed by "eventType:deviceId") +// Persists across calls within the same process lifetime +const cooldownUntil = new Map(); + +const EVENT_LABELS: Record = { + 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 = { + 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 { + 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( + `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 { + const rows = await query( + `SELECT * FROM alert_rules WHERE event_type = $1`, + [eventType] + ); + return rows[0] ?? null; + } + + async getRules(): Promise { + return query(`SELECT * FROM alert_rules ORDER BY event_type`); + } + + async upsertRule(eventType: string, data: Partial): Promise { + const rows = await query( + `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 { + const rows = await query( + `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 { + 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, + eventType: string, + message: string, + ctx: AlertContext + ): Promise { + 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 ? `Device: ${ctx.deviceName}
` : ''; + const details = ctx.details ? `
${ctx.details}
` : ''; + + const html = ` +
+
+ Mikrotik Manager Alert +
+
+

${EVENT_EMOJI[eventType] ?? '🔔'} ${label}

+

${message}

+
${device}${details}
+
+

Sent by Mikrotik Manager · ${new Date().toUTCString()}

+
+
`; + + await transport.sendMail({ from, to: recipients.join(', '), subject, html }); + } + + private async sendSlack( + cfg: Record, + eventType: string, + message: string, + ctx: AlertContext + ): Promise { + 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, + eventType: string, + message: string, + ctx: AlertContext + ): Promise { + 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, + eventType: string, + message: string, + ctx: AlertContext + ): Promise { + 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📡 Device: ${ctx.deviceName}` : ''; + const detail = ctx.details ? `\n
${ctx.details}
` : ''; + + const text = `${EVENT_EMOJI[eventType] ?? '🔔'} ${label}\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 { + 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(); diff --git a/backend/src/services/BackupService.ts b/backend/src/services/BackupService.ts new file mode 100644 index 0000000..605eaf3 --- /dev/null +++ b/backend/src/services/BackupService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 }); + }); + } +} diff --git a/backend/src/services/PollerService.ts b/backend/src/services/PollerService.ts new file mode 100644 index 0000000..4c9379b --- /dev/null +++ b/backend/src/services/PollerService.ts @@ -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 | 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 { + this.startWorkers(); + this.startScheduler(); + console.log('PollerService started'); + } + + async stop(): Promise { + 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 { + 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 { + 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( + `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> { + 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 = {}; + for (const row of rows) map[row.key] = row.value; + return map; + } catch { + return {}; + } + } + + private async resolveClientHostnames(): Promise { + 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): Promise { + // 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 { + 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 { + 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 { + 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) => { + await this.processPollJob(job.data); + }, + workerOptions + ); + + this.slowWorker = new Worker( + 'poll-slow', + async (job: Job) => { + await this.processSlowJob(job.data); + }, + { ...workerOptions, connection: createRedisConnection() } + ); + + this.logsWorker = new Worker( + 'poll-logs', + async (job: Job) => { + 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 { + 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[] + ): { freq: number; magn: number; peak: number }[] { + const map = new Map(); + 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[] }[], + 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(); + + 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 { + 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 { + 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[] }[] = []; + for (const iface of ifaces) { + const rows = await collector.scanWireless(iface.name).catch(() => [] as Record[]); + 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 { + 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 { + 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 { + 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 { + // 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 { + const rows = await query(`SELECT * FROM devices WHERE id = $1`, [deviceId]); + return rows[0] || null; + } +} diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts new file mode 100644 index 0000000..3fdaacf --- /dev/null +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -0,0 +1,2392 @@ +import { RouterOSClient } from './RouterOSClient'; +import { query, queryOne } from '../../config/database'; +import { getWriteApi } from '../../config/influxdb'; +import { Point } from '@influxdata/influxdb-client'; +import { decrypt } from '../../utils/crypto'; +import { lookupVendor } from '../../utils/oui'; +import { buildServerArpMap } from '../../utils/serverArp'; + +export interface DeviceRow { + id: number; + name: string; + ip_address: string; + api_port: number; + api_username: string; + api_password_encrypted: string; + ssh_username?: string; + ssh_password_encrypted?: string; + model?: string; + ros_version?: string; + device_type: string; + status: string; +} + +export class DeviceCollector { + private client: RouterOSClient; + + constructor(private device: DeviceRow) { + this.client = new RouterOSClient( + device.ip_address, + device.api_port, + device.api_username, + decrypt(device.api_password_encrypted) + ); + } + + async connect(): Promise { + await this.client.connect(); + } + + disconnect(): void { + this.client.disconnect(); + } + + // ─── Fast poll (every 30s) ───────────────────────────────────────────────── + + async collectFast(): Promise { + await this.collectInterfaceTraffic(); + await this.collectResourceUsage(); + await this.updateClients(); + if (this.device.device_type === 'wireless_ap') { + await this.collectWirelessStats(); + } + await this.updateDeviceStatus('online'); + } + + // ─── Slow poll (every 5 min) ─────────────────────────────────────────────── + + async collectSlow(): Promise { + await this.collectInterfaces(); + await this.collectVlans(); + await this.collectSystemInfo(); + await this.collectStp(); + if (this.device.device_type === 'wireless_ap') { + await this.collectWirelessInterfaces(); + await this.collectSecurityProfiles(); + } + } + + // ─── Log poll (every 60s) ───────────────────────────────────────────────── + + async collectLogs(): Promise { + await this.collectEvents(); + } + + // ─── Full initial collection ─────────────────────────────────────────────── + + async collectAll(): Promise { + await this.collectSystemInfo(); + await this.collectInterfaces(); + await this.collectVlans(); + await this.collectInterfaceTraffic(); + await this.collectResourceUsage(); + await this.updateClients(); + await this.collectEvents(); + await this.collectNeighbors(); + await this.saveFullConfig(); + await this.updateDeviceStatus('online'); + } + + // ─── System Info ────────────────────────────────────────────────────────── + + async collectSystemInfo(): Promise { + try { + const identity = await this.client.execute('/system/identity/print'); + const resource = await this.client.execute('/system/resource/print'); + const routerboard = await this.client.execute('/system/routerboard/print').catch(() => [] as Record[]); + + const info = resource[0] || {}; + const rb = routerboard[0] || {}; + const identityName = identity[0]?.['name'] || this.device.name; + + const rosVersion = (info['version'] || '').split(' ')[0]; + const model = rb['model'] || info['board-name'] || null; + const serial = rb['serial-number'] || null; + const firmware = rb['current-firmware'] || rb['factory-firmware'] || null; + + await query( + `UPDATE devices SET + name = COALESCE($1, name), + model = COALESCE($2, model), + serial_number = COALESCE($3, serial_number), + firmware_version = COALESCE($4, firmware_version), + ros_version = COALESCE($5, ros_version), + updated_at = NOW() + WHERE id = $6`, + [identityName, model, serial, firmware, rosVersion, this.device.id] + ); + } catch (err) { + console.error(`[${this.device.name}] Failed to collect system info:`, err); + } + } + + // ─── Interface Stats → InfluxDB ─────────────────────────────────────────── + + async collectInterfaceTraffic(): Promise { + try { + const stats = await this.client.execute('/interface/print', { stats: '' }); + const writeApi = getWriteApi(); + + for (const iface of stats) { + const name = iface['name']; + if (!name) continue; + + const point = new Point('interface_traffic') + .tag('device_id', String(this.device.id)) + .tag('device_name', this.device.name) + .tag('interface', name) + .intField('rx_bytes', parseInt(iface['rx-byte'] || '0', 10)) + .intField('tx_bytes', parseInt(iface['tx-byte'] || '0', 10)) + .intField('rx_packets', parseInt(iface['rx-packet'] || '0', 10)) + .intField('tx_packets', parseInt(iface['tx-packet'] || '0', 10)) + .intField('rx_errors', parseInt(iface['rx-error'] || '0', 10)) + .intField('tx_errors', parseInt(iface['tx-error'] || '0', 10)) + .booleanField('running', iface['running'] === 'true') + .timestamp(new Date()); + + writeApi.writePoint(point); + } + + await writeApi.flush().catch((e) => console.error('InfluxDB flush error:', e)); + } catch (err) { + console.error(`[${this.device.name}] Failed to collect interface traffic:`, err); + } + } + + // ─── Resource Usage → InfluxDB ──────────────────────────────────────────── + + async collectResourceUsage(): Promise { + try { + const res = await this.client.execute('/system/resource/print'); + const r = res[0]; + if (!r) return; + + const writeApi = getWriteApi(); + const point = new Point('device_resources') + .tag('device_id', String(this.device.id)) + .tag('device_name', this.device.name) + .floatField('cpu_load', parseFloat(r['cpu-load'] || '0')) + .intField('memory_total', parseInt(r['total-memory'] || '0', 10)) + .intField( + 'memory_used', + parseInt(r['total-memory'] || '0', 10) - parseInt(r['free-memory'] || '0', 10) + ) + .intField('hdd_total', parseInt(r['total-hdd-space'] || '0', 10)) + .intField( + 'hdd_used', + parseInt(r['total-hdd-space'] || '0', 10) - + parseInt(r['free-hdd-space'] || '0', 10) + ) + .intField('uptime_seconds', this.parseUptime(r['uptime'] || '0s')) + .timestamp(new Date()); + + writeApi.writePoint(point); + await writeApi.flush().catch((e) => console.error('InfluxDB flush error:', e)); + } catch (err) { + console.error(`[${this.device.name}] Failed to collect resources:`, err); + } + } + + private parseUptime(uptime: string): number { + let seconds = 0; + const weeks = uptime.match(/(\d+)w/); + const days = uptime.match(/(\d+)d/); + const hours = uptime.match(/(\d+)h/); + const mins = uptime.match(/(\d+)m/); + const secs = uptime.match(/(\d+)s/); + if (weeks) seconds += parseInt(weeks[1]) * 604800; + if (days) seconds += parseInt(days[1]) * 86400; + if (hours) seconds += parseInt(hours[1]) * 3600; + if (mins) seconds += parseInt(mins[1]) * 60; + if (secs) seconds += parseInt(secs[1]); + return seconds; + } + + // ─── Interfaces → Postgres ──────────────────────────────────────────────── + + async collectInterfaces(): Promise { + try { + const [ifaces, bridges, bonds] = await Promise.all([ + this.client.execute('/interface/print', { detail: '' }), + this.client.execute('/interface/bridge/print', { detail: '' }).catch(() => [] as Record[]), + this.client.execute('/interface/bonding/print', { detail: '' }).catch(() => [] as Record[]), + ]); + + const bridgeNames = new Set(bridges.map((b) => b['name']).filter(Boolean)); + const bondNames = new Set(bonds.map((b) => b['name']).filter(Boolean)); + + // Map bridge name → full bridge data (includes vlan-filtering, etc.) + const bridgeDataMap = new Map>(); + for (const b of bridges) { + if (b['name']) bridgeDataMap.set(b['name'], b); + } + + const bridgesNotInIfaces = bridges.filter((b) => b['name'] && !ifaces.some((i) => i['name'] === b['name'])); + const bondsNotInIfaces = bonds.filter((b) => b['name'] && !ifaces.some((i) => i['name'] === b['name'])); + const allIfaces = [...ifaces, ...bridgesNotInIfaces, ...bondsNotInIfaces]; + + for (const iface of allIfaces) { + const name = iface['name']; + if (!name) continue; + const rosType = iface['type'] || 'ether'; + const resolvedType = bridgeNames.has(name) ? 'bridge' : bondNames.has(name) ? 'bond' : rosType; + + // For bridge interfaces, merge bridge-specific properties (vlan-filtering, etc.) + // /interface/print lacks bridge-only fields; /interface/bridge/print has them. + const enrichedIface = resolvedType === 'bridge' && bridgeDataMap.has(name) + ? { ...iface, ...bridgeDataMap.get(name)! } + : iface; + + // RouterOS bridges may report mtu as 'auto' or '...' (inherited); use actual-mtu as fallback + const rawMtu = parseInt(enrichedIface['actual-mtu'] || enrichedIface['mtu'] || '0', 10); + const mtu = !isNaN(rawMtu) && rawMtu > 0 ? rawMtu : null; + + try { + await query( + `INSERT INTO interfaces (device_id, name, type, mac_address, mtu, running, disabled, comment, speed, config_json, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW()) + ON CONFLICT (device_id, name) DO UPDATE SET + type=$3, mac_address=$4, mtu=$5, running=$6, disabled=$7, comment=$8, speed=$9, config_json=$10, updated_at=NOW()`, + [ + this.device.id, + name, + resolvedType, + enrichedIface['mac-address'] || null, + mtu, + enrichedIface['running'] === 'true', + enrichedIface['disabled'] === 'true', + enrichedIface['comment'] || null, + enrichedIface['speed'] || null, + JSON.stringify(enrichedIface), + ] + ); + } catch (insertErr) { + console.error(`[${this.device.name}] Failed to insert interface ${name}:`, insertErr); + } + } + } catch (err) { + console.error(`[${this.device.name}] Failed to collect interfaces:`, err); + } + } + + // ─── VLANs → Postgres ──────────────────────────────────────────────────── + + async collectVlans(): Promise { + try { + // Bridge VLAN table (CRS switches / bridge-based switching) + const bridgeVlans = await this.client + .execute('/interface/bridge/vlan/print', { detail: '' }) + .catch(() => []); + + for (const vlan of bridgeVlans) { + const vlanId = parseInt(vlan['vlan-ids'] || '0', 10); + if (!vlanId) continue; + + await query( + `INSERT INTO vlans (device_id, vlan_id, name, bridge, tagged_ports, untagged_ports, config_json) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (device_id, vlan_id) DO UPDATE SET + name=$3, bridge=$4, tagged_ports=$5, untagged_ports=$6, config_json=$7`, + [ + this.device.id, + vlanId, + vlan['comment'] || `VLAN ${vlanId}`, + vlan['bridge'] || null, + this.parseList(vlan['tagged']), + this.parseList(vlan['untagged']), + JSON.stringify(vlan), + ] + ); + } + + // Also collect bridge port PVIDs + const bridgePorts = await this.client + .execute('/interface/bridge/port/print', { detail: '' }) + .catch(() => []); + + for (const port of bridgePorts) { + const pvid = parseInt(port['pvid'] || '1', 10); + const bridge = port['bridge'] || ''; + const portName = port['interface'] || ''; + if (!portName || !bridge) continue; + + await query( + `INSERT INTO bridge_vlan_entries (device_id, bridge, port, pvid, tagged, config_json, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (device_id, bridge, port) DO UPDATE SET + pvid=$4, tagged=$5, config_json=$6, updated_at=NOW()`, + [ + this.device.id, + bridge, + portName, + pvid, + false, + JSON.stringify(port), + ] + ); + } + } catch (err) { + console.error(`[${this.device.name}] Failed to collect VLANs:`, err); + } + } + + private parseList(val: string | undefined): string[] { + if (!val) return []; + return val + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } + + // ─── Clients ────────────────────────────────────────────────────────────── + + async updateClients(): Promise { + try { + await query(`UPDATE clients SET active = FALSE WHERE device_id = $1`, [this.device.id]); + + // Detect which wireless package is in use before the parallel fetch so we + // query the correct registration table path (new wifi pkg vs legacy wireless pkg). + const wifiPkg = await this.detectWifiPackage().catch(() => 'none' as const); + const regTableCmd = wifiPkg === 'wifi' + ? '/interface/wifi/registration-table/print' + : '/interface/wireless/registration-table/print'; + + // Collect all data sources in parallel. + // Note: { detail: '' } is omitted — the RouterOS binary API always returns all fields, + // and passing =detail= causes a silent !trap on some RouterOS builds. + const [arpEntries, dhcpLeases, wirelessClients, bridgeHosts] = await Promise.all([ + this.client.execute('/ip/arp/print').catch(() => []), + this.client.execute('/ip/dhcp-server/lease/print').catch(() => []), + wifiPkg === 'none' + ? Promise.resolve([] as Record[]) + : this.client.execute(regTableCmd).catch(() => [] as Record[]), + this.client.execute('/interface/bridge/host/print').catch(() => []), + ]); + + // DHCP hostname + IP lookup + const dhcpHostnames: Record = {}; + const dhcpIPs: Record = {}; + for (const lease of dhcpLeases) { + const mac = (lease['mac-address'] || '').toLowerCase(); + if (mac) { + dhcpHostnames[mac] = lease['host-name'] || lease['comment'] || ''; + dhcpIPs[mac] = lease['address'] || ''; + } + } + + // Wireless signal / traffic data + // Field names differ between packages: + // legacy: signal-strength (e.g. "-65dBm"), bytes ("rx,tx" combined) + // new wifi pkg: signal (e.g. "-65"), tx-bytes / rx-bytes (separate fields) + const wifiSignal: Record = {}; + const wifiInterface: Record = {}; + const wifiTx: Record = {}; + const wifiRx: Record = {}; + for (const wc of wirelessClients) { + const mac = (wc['mac-address'] || '').toLowerCase(); + if (!mac) continue; + // Signal strength — strip any trailing unit suffix (e.g. "dBm") + const rawSignal = wc['signal-strength'] || wc['signal'] || '0'; + wifiSignal[mac] = parseInt(rawSignal, 10) || 0; + wifiInterface[mac] = wc['interface'] || ''; + // Traffic counters — new wifi pkg has separate fields; legacy combines as "rx,tx" + if (wc['tx-bytes'] !== undefined || wc['rx-bytes'] !== undefined) { + wifiTx[mac] = parseInt(wc['tx-bytes'] || '0', 10) || 0; + wifiRx[mac] = parseInt(wc['rx-bytes'] || '0', 10) || 0; + } else { + const parts = (wc['bytes'] || '0,0').split(','); + wifiRx[mac] = parseInt(parts[0] || '0', 10) || 0; + wifiTx[mac] = parseInt(parts[1] || '0', 10) || 0; + } + } + + // ARP table — IP enrichment and activity validation. + // Only accept entries where ARP resolution succeeded (status != failed). + // RouterOS v7 marks stale/unreachable entries with status=failed. + const arpIPs: Record = {}; + const arpInterfaces: Record = {}; + const arpFailed = new Set(); // MACs confirmed offline by this device's ARP + for (const arp of arpEntries) { + const mac = (arp['mac-address'] || '').toLowerCase(); + if (!mac) continue; + if (arp['incomplete'] === 'true') continue; + if (arp['BCAST'] === 'true') continue; + if (arp['status'] === 'failed') { arpFailed.add(mac); continue; } + arpIPs[mac] = arp['address'] || ''; + arpInterfaces[mac] = arp['interface'] || ''; + } + + + // Bridge host table — primary client source for switched/bridged devices. + // RouterOS includes an 'age' field (seconds since last frame seen). + // On software bridges (CCR, CHR) the table ages out at ~300s, so anything + // present is genuinely recent. On hardware CRS switches the ASIC may hold + // entries for hours after a host goes offline. + // Strategy: + // - Skip static/local entries + // - Skip any entry where age > 600s AND the device's own ARP marks it failed + // (belt-and-suspenders for CRS hardware whose FDB doesn't self-age) + // - Keep all others (missing age field = software bridge, treat as fresh) + const MAX_BRIDGE_AGE_S = 600; + const bridgeHostMap: Record = {}; + for (const host of bridgeHosts) { + if (host['local'] === 'true') continue; + if (host['dynamic'] === 'false') continue; + const mac = (host['mac-address'] || '').toLowerCase(); + if (!mac) continue; + const age = host['age'] ? parseInt(host['age'], 10) : 0; + // Exclude stale hardware-FDB entries: old AND ARP-confirmed offline + if (age > MAX_BRIDGE_AGE_S && arpFailed.has(mac)) continue; + bridgeHostMap[mac] = { + port: host['on-interface'] || host['interface'] || '', + vid: host['vid'] ? parseInt(host['vid'], 10) : null, + }; + } + + const allMacs = new Set([ + ...Object.keys(wifiSignal), + ...Object.keys(arpIPs), + ...Object.keys(bridgeHostMap), + ]); + + let totalClients = 0; + for (const mac of allMacs) { + if (!mac) continue; + const isWireless = mac in wifiSignal; + const entry = bridgeHostMap[mac]; + const interfaceName = isWireless + ? (wifiInterface[mac] || entry?.port || null) + : (entry?.port || arpInterfaces[mac] || null); + const vlanId = entry?.vid ?? null; + + await query( + `INSERT INTO clients (device_id, mac_address, hostname, ip_address, interface_name, vlan_id, tx_bytes, rx_bytes, signal_strength, client_type, active, last_seen) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,NOW()) + ON CONFLICT (device_id, mac_address) DO UPDATE SET + hostname=COALESCE($3, clients.hostname), + ip_address=COALESCE(NULLIF($4,''), clients.ip_address), + interface_name=COALESCE($5, clients.interface_name), + vlan_id=COALESCE($6, clients.vlan_id), + tx_bytes=$7, + rx_bytes=$8, + signal_strength=$9, + client_type=$10, + active=TRUE, + last_seen=NOW()`, + [ + this.device.id, + mac, + dhcpHostnames[mac] || null, + dhcpIPs[mac] || arpIPs[mac] || null, + interfaceName, + vlanId, + wifiTx[mac] || 0, + wifiRx[mac] || 0, + isWireless ? wifiSignal[mac] : null, + isWireless ? 'wireless' : 'wired', + ] + ); + totalClients++; + } + + // OUI vendor lookup: fill in up to 10 clients that are missing vendor per cycle + await this.lookupMissingVendors(); + + // Write per-client presence points so the detail page can show a timeline + const writeApi = getWriteApi(); + for (const mac of allMacs) { + if (!mac) continue; + const presencePoint = new Point('client_presence') + .tag('mac_address', mac) + .tag('device_id', String(this.device.id)) + .intField('online', 1); + if (mac in wifiSignal) { + presencePoint.intField('signal_strength', wifiSignal[mac]); + presencePoint.intField('tx_bytes', wifiTx[mac] ?? 0); + presencePoint.intField('rx_bytes', wifiRx[mac] ?? 0); + } + presencePoint.timestamp(new Date()); + writeApi.writePoint(presencePoint); + } + + // Write per-device metric (for per-device breakdowns) + const point = new Point('client_counts') + .tag('device_id', String(this.device.id)) + .tag('device_name', this.device.name) + .intField('total_clients', totalClients) + .intField('wireless_clients', Object.keys(wifiSignal).length) + .intField('wired_clients', Math.max(0, totalClients - Object.keys(wifiSignal).length)) + .timestamp(new Date()); + writeApi.writePoint(point); + + // Write a global deduplicated metric so the dashboard graph shows the true + // unique client count across all devices (not a per-device sum that double-counts + // clients visible from multiple devices simultaneously). + 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); + const globalPoint = new Point('client_counts') + .tag('device_id', '_global') + .tag('device_name', '_global') + .intField('total_clients', globalTotal) + .timestamp(new Date()); + writeApi.writePoint(globalPoint); + + await writeApi.flush().catch(() => {}); + } catch (err) { + console.error(`[${this.device.name}] Failed to update clients:`, err); + } + } + + private async lookupMissingVendors(): Promise { + try { + // Include vendor='' entries: those were set by the old API-based lookup + // when it was rate-limited and never actually resolved. + const rows = await query<{ mac_address: string }>( + `SELECT mac_address FROM clients + WHERE device_id = $1 AND (vendor IS NULL OR vendor = '') AND active = TRUE`, + [this.device.id] + ); + for (const row of rows) { + const vendor = lookupVendor(row.mac_address); + // Always write the result (even empty) so it's consistent across all + // device rows for the same MAC. + await query( + `UPDATE clients SET vendor = $1 WHERE device_id = $2 AND mac_address = $3`, + [vendor, this.device.id, row.mac_address] + ); + } + } catch { + // Non-critical; ignore vendor lookup failures + } + } + + // ─── Events/Logs ───────────────────────────────────────────────────────── + + async collectEvents(): Promise { + try { + const logs = await this.client.execute('/log/print'); + if (!logs.length) return; + + // RouterOS log .id values are hex strings like "*1A2F". Parse to int for comparison. + const parseRosId = (id: string): number => { + const hex = (id || '').replace(/^\*/, ''); + return hex ? parseInt(hex, 16) : 0; + }; + + // Find the highest RouterOS log ID we've already stored for this device. + const lastRow = await queryOne<{ log_id: string }>( + `SELECT log_id FROM events WHERE device_id = $1 AND log_id IS NOT NULL AND log_id != '' + ORDER BY id DESC LIMIT 1`, + [this.device.id] + ); + const lastIdNum = parseRosId(lastRow?.log_id || ''); + + // Always get the latest stored event time — needed for timestamp fallback. + const latestStored = await queryOne<{ event_time: Date }>( + `SELECT event_time FROM events WHERE device_id = $1 ORDER BY event_time DESC LIMIT 1`, + [this.device.id] + ); + const latestTime = latestStored?.event_time ? new Date(latestStored.event_time) : new Date(0); + + // Detect log buffer overflow or device reboot: if all current IDs are below + // our stored lastIdNum, RouterOS has cleared/reset its log buffer. + // In that case, fall back to timestamp-based deduplication to avoid missing events. + const currentIds = logs.map(l => parseRosId(l['.id'] || '')).filter(id => id > 0); + const maxCurrentId = currentIds.length > 0 ? Math.max(...currentIds) : 0; + const logReset = lastRow && maxCurrentId > 0 && maxCurrentId < lastIdNum; + + let newCount = 0; + for (const log of logs) { + const logId = (log['.id'] || '') as string; + const logIdNum = parseRosId(logId); + + if (logId && !logReset) { + // Primary: skip entries already stored by RouterOS ID + if (logIdNum <= lastIdNum) continue; + } else { + // Fallback: no .id field, or log buffer has been reset — use timestamp deduplication + const time = this.parseLogTime(log['time'] || ''); + if (time <= latestTime) continue; + } + + const time = this.parseLogTime(log['time'] || ''); + const severity = this.mapLogSeverity(log['topics'] || ''); + + await query( + `INSERT INTO events (device_id, event_time, severity, topic, message, raw_json, log_id) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (device_id, log_id) DO NOTHING`, + [ + this.device.id, + time.toISOString(), + severity, + log['topics'] || null, + log['message'] || '', + JSON.stringify(log), + logId || null, + ] + ); + newCount++; + } + + if (newCount > 0) { + await query( + `DELETE FROM events WHERE device_id = $1 AND event_time < NOW() - INTERVAL '30 days'`, + [this.device.id] + ); + console.log(`[${this.device.name}] Collected ${newCount} new log entries`); + } + } catch (err) { + console.error(`[${this.device.name}] Failed to collect events:`, err); + } + } + + private parseLogTime(timeStr: string): Date { + // RouterOS log time formats: + // "jan/01 00:00:05" — month/day only (no year) → default to current year + // "jan/01/2024 00:00:05" — full date with year + // "00:00:05" — time only (today, e.g. device uptime < 1 day) + if (!timeStr) return new Date(); + try { + if (timeStr.includes('/')) { + const spaceIdx = timeStr.lastIndexOf(' '); + if (spaceIdx === -1) return new Date(); + const datePart = timeStr.substring(0, spaceIdx); + const timePart = timeStr.substring(spaceIdx + 1); + const parts = datePart.split('/'); + const monthStr = parts[0]; + const day = parts[1]; + if (!monthStr || !day) return new Date(); + const year = parts[2] !== undefined ? parts[2] : String(new Date().getFullYear()); + const months: Record = { + jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06', + jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12', + }; + const month = months[monthStr.toLowerCase()] || '01'; + const d = new Date(`${year}-${month}-${day.padStart(2, '0')}T${timePart}Z`); + if (isNaN(d.getTime())) return new Date(); + return d; + } else { + // Time only (today) + const today = new Date().toISOString().split('T')[0]; + const d = new Date(`${today}T${timeStr}Z`); + if (isNaN(d.getTime())) return new Date(); + return d; + } + } catch { + return new Date(); + } + } + + private mapLogSeverity(topics: string): string { + if (topics.includes('critical') || topics.includes('error')) return 'error'; + if (topics.includes('warning')) return 'warning'; + if (topics.includes('info')) return 'info'; + return 'info'; + } + + // ─── MAC Scan (switch IP enrichment) ───────────────────────────────────── + + async runMacScan(): Promise { + if (this.device.device_type !== 'switch') return; + try { + const bridges = await this.client + .execute('/interface/bridge/print', { detail: '' }) + .catch(() => [] as Record[]); + + const bridgeNames = bridges.map((b) => b['name']).filter(Boolean); + if (bridgeNames.length === 0) return; + + const macIpMap: Record = {}; + + // Scan each bridge for 5 seconds; executeStreaming handles the !done or + // cuts off at 8 s so we don't stall the poller indefinitely + for (const iface of bridgeNames) { + const results = await this.client + .executeStreaming('/tool/mac-scan', { interface: iface, duration: '5' }, 8_000) + .catch(() => [] as Record[]); + + for (const entry of results) { + const mac = (entry['mac-address'] || '').toLowerCase(); + const ip = entry['address'] || ''; + if (mac && ip) macIpMap[mac] = ip; + } + } + + if (Object.keys(macIpMap).length === 0) return; + + // Enrich client records across all devices: fill in missing IPs only, + // so DHCP-assigned addresses that already exist are not overwritten. + for (const [mac, ip] of Object.entries(macIpMap)) { + await query( + `UPDATE clients SET ip_address = $1 + WHERE mac_address = $2 + AND (ip_address IS NULL OR ip_address = '')`, + [ip, mac] + ); + } + + console.log(`[${this.device.name}] MAC scan found ${Object.keys(macIpMap).length} MAC/IP pair(s)`); + } catch (err) { + console.error(`[${this.device.name}] MAC scan failed:`, err); + } + } + + // ─── Neighbor Discovery (for topology) ─────────────────────────────────── + + async collectNeighbors(): Promise { + try { + const [neighbors, arpEntries, dhcpLeases, serverArpMap] = await Promise.all([ + this.client.execute('/ip/neighbor/print').catch(() => []), + this.client.execute('/ip/arp/print').catch(() => []), + this.client.execute('/ip/dhcp-server/lease/print').catch(() => []), + buildServerArpMap(), + ]); + + // Build MAC → IPv4 lookup from ARP and DHCP as fallback for neighbors + // whose MNDP advertisement only contains an IPv6 address. + const macToIpv4: Record = {}; + for (const arp of arpEntries) { + const mac = (arp['mac-address'] || '').toLowerCase().trim(); + const ip = (arp['address'] || '').trim(); + if (!mac || !ip || ip.includes(':')) continue; + if (arp['complete'] === 'false' || arp['status'] === 'failed') continue; + macToIpv4[mac] = ip; + } + for (const lease of dhcpLeases) { + const mac = (lease['mac-address'] || '').toLowerCase().trim(); + const ip = (lease['address'] || '').trim(); + if (mac && ip && !ip.includes(':') && !macToIpv4[mac]) macToIpv4[mac] = ip; + } + + // Wipe existing rows for this device so removed neighbors don't linger + await query(`DELETE FROM topology_links WHERE from_device_id = $1`, [this.device.id]); + + for (const nb of neighbors) { + // RouterOS returns comma-separated interface names like "ether1,bridge1" — the + // first entry is always the physical port; the rest are the bridge/bond parents. + // Store only the physical port so topology edges show the actual cable endpoint. + const rawInterface = nb['interface'] || ''; + const fromInterface = rawInterface.split(',')[0].trim() || rawInterface; + + // interface-name is the neighbor's own outgoing interface (format: bridge/port or just port) + const toInterface = (nb['interface-name'] || '').trim() || null; + + // discovered-by lists protocols that found this neighbor: lldp, cdp, mndp, etc. + // Rank them by reliability: lldp (point-to-point) > cdp (also flooded in ROS but + // implies same segment) > mndp (MikroTik broadcast, can span entire bridge domain). + const discoveredByRaw = (nb['discovered-by'] || '').toLowerCase(); + let discoveredBy: string; + if (discoveredByRaw.includes('lldp')) discoveredBy = 'lldp'; + else if (discoveredByRaw.includes('cdp')) discoveredBy = 'cdp'; + else if (discoveredByRaw.includes('mndp')) discoveredBy = 'mndp'; + else discoveredBy = discoveredByRaw || 'mndp'; + + // RouterOS v7+ may put an IPv6 link-local in 'address'. Try all known + // IPv4 field names, split on whitespace/commas in case multiple are packed + // into one field, and take the first value that looks like IPv4. + const mac = (nb['mac-address'] || '').toLowerCase().trim(); + const ipv4FromNeighbor = [ + nb['ipv4-address'], + nb['ip-address'], + nb['address'], + ] + .flatMap((f) => (f || '').split(/[\s,]+/)) + .map((s) => s.trim()) + .find((s) => s && !s.includes(':')) ?? null; + + const neighborAddress = ipv4FromNeighbor + || (mac ? macToIpv4[mac] ?? null : null) + || (mac ? serverArpMap[mac] ?? null : null); + const neighborIdentity = nb['identity'] || ''; + const neighborPlatform = nb['platform'] || ''; + const neighborMac = nb['mac-address'] || null; + const neighborCaps = nb['system-caps-enabled'] || nb['system-caps'] || ''; + + if (!fromInterface) continue; + + await query( + `INSERT INTO topology_links + (from_device_id, from_interface, to_interface, neighbor_address, neighbor_identity, + neighbor_platform, neighbor_mac, neighbor_caps, link_type, discovered_by, discovered_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())`, + [this.device.id, fromInterface, toInterface, neighborAddress, neighborIdentity, + neighborPlatform, neighborMac, neighborCaps || null, discoveredBy, discoveredByRaw || null] + ); + } + + // Try to resolve neighbor_address to a known device + await query( + `UPDATE topology_links tl + SET to_device_id = d.id + FROM devices d + WHERE tl.from_device_id = $1 + AND d.ip_address = tl.neighbor_address + AND tl.to_device_id IS DISTINCT FROM d.id`, + [this.device.id] + ); + } catch (err) { + console.error(`[${this.device.name}] Failed to collect neighbors:`, err); + } + } + + async collectStp(): Promise { + try { + const bridgePorts = await this.client + .execute('/interface/bridge/port/print', { detail: '' }) + .catch(() => []); + + for (const port of bridgePorts) { + const iface = (port['interface'] as string) || ''; + const role = (port['role'] as string) || ''; + const bridgeName = (port['bridge'] as string) || ''; + // Derive state from role — alternate/backup are blocking, root/designated are forwarding + const state = (role === 'alternate' || role === 'backup') ? 'blocking' : 'forwarding'; + + if (!iface) continue; + + await query( + `UPDATE topology_links + SET stp_role=$3, stp_state=$4, bridge_name=$5 + WHERE from_device_id=$1 AND from_interface=$2`, + [this.device.id, iface, role || null, state, bridgeName || null] + ); + } + } catch (err) { + console.error(`[${this.device.name}] Failed to collect STP:`, err); + } + } + + // ─── LLDP Management ────────────────────────────────────────────────────── + + async getLldpEnabled(): Promise<{ enabled: boolean; protocol: string }> { + try { + const result = await this.client + .execute('/ip/neighbor/discovery-settings/print') + .catch(() => [] as Record[]); + const settings = result[0] as Record | undefined; + const protocol = settings?.['protocol'] ?? ''; + if (!protocol) return { enabled: true, protocol: 'unknown' }; + const enabled = protocol.toLowerCase().includes('lldp'); + return { enabled, protocol }; + } catch { + return { enabled: true, protocol: 'unknown' }; + } + } + + async setLldpEnabled(enabled: boolean): Promise { + const current = await this.getLldpEnabled(); + const currentProtocol = current.protocol === 'unknown' ? 'cdp,lldp,mndp' : current.protocol; + let protocols = currentProtocol + .split(',') + .map((p: string) => p.trim()) + .filter((p: string) => p && p.toLowerCase() !== 'lldp'); + if (enabled) protocols = [...protocols, 'lldp']; + const newProtocol = protocols.join(',') || 'mndp'; + await this.client.execute('/ip/neighbor/discovery-settings/set', { protocol: newProtocol }); + } + + // ─── SNMP Management ────────────────────────────────────────────────────── + + async getSnmpConfig(): Promise<{ + enabled: boolean; contact: string; location: string; trap_target: string; + community_name: string; version: 'v1' | 'v2c' | 'v3'; + auth_protocol: string; priv_protocol: string; + }> { + try { + const [globals, communities] = await Promise.all([ + this.client.execute('/snmp/print').catch(() => [] as Record[]), + this.client.execute('/snmp/community/print').catch(() => [] as Record[]), + ]); + const g = (globals as Record[])[0] ?? {}; + const enabled = g['enabled'] !== 'false' && g['enabled'] !== 'no'; + const contact = g['contact'] ?? ''; + const location = g['location'] ?? ''; + const trap_target = g['trap-target'] ?? ''; + const trapVersion = g['trap-version'] ?? '2'; + + // Pick first non-"public" community, fall back to first + const comm = ((communities as Record[]).find(c => c['name'] !== 'public') + ?? (communities as Record[])[0]) ?? {}; + const community_name = comm['name'] ?? 'public'; + const security = comm['security'] ?? 'none'; + const auth_protocol = comm['authentication-protocol'] ?? 'MD5'; + const priv_protocol = comm['encryption-protocol'] ?? 'none'; + + let version: 'v1' | 'v2c' | 'v3'; + if (security === 'authorized' || security === 'private') { + version = 'v3'; + } else { + version = trapVersion === '1' ? 'v1' : 'v2c'; + } + + return { enabled, contact, location, trap_target, community_name, version, auth_protocol, priv_protocol }; + } catch { + return { enabled: false, contact: '', location: '', trap_target: '', community_name: 'public', version: 'v2c', auth_protocol: 'MD5', priv_protocol: 'none' }; + } + } + + async setSnmpConfig(config: { + enabled: boolean; contact?: string; location?: string; trap_target?: string; + community_name: string; version: 'v1' | 'v2c' | 'v3'; + auth_protocol?: string; auth_password?: string; + priv_protocol?: string; priv_password?: string; + }): Promise { + // 1. Global settings + const globalParams: Record = { enabled: config.enabled ? 'yes' : 'no' }; + if (config.contact !== undefined) globalParams['contact'] = config.contact; + if (config.location !== undefined) globalParams['location'] = config.location; + if (config.trap_target !== undefined) globalParams['trap-target'] = config.trap_target; + globalParams['trap-version'] = config.version === 'v1' ? '1' : config.version === 'v3' ? '3' : '2'; + await this.client.execute('/snmp/set', globalParams); + + // 2. Community security level + let security = 'none'; + if (config.version === 'v3') { + const hasPriv = config.priv_protocol && config.priv_protocol !== 'none' && config.priv_password; + security = hasPriv ? 'private' : 'authorized'; + } + + const communityParams: Record = { name: config.community_name, security }; + if (config.version === 'v3') { + if (config.auth_protocol && config.auth_protocol !== 'none') { + communityParams['authentication-protocol'] = config.auth_protocol; + if (config.auth_password) communityParams['authentication-password'] = config.auth_password; + } + if (config.priv_protocol && config.priv_protocol !== 'none') { + communityParams['encryption-protocol'] = config.priv_protocol; + if (config.priv_password) communityParams['encryption-password'] = config.priv_password; + } + } + + // Find existing community by name or fall back to first + const communities = await this.client.execute('/snmp/community/print').catch(() => [] as Record[]); + const existing = (communities as Record[]).find(c => c['name'] === config.community_name) + ?? (communities as Record[])[0]; + + if (existing?.['.id']) { + await this.client.execute('/snmp/community/set', { '.id': existing['.id'], ...communityParams }); + } else { + await this.client.execute('/snmp/community/add', communityParams); + } + } + + // ─── Full config snapshot ───────────────────────────────────────────────── + + async saveFullConfig(): Promise { + try { + const [interfaces, vlans, routes, firewall, dhcp, dns] = await Promise.all([ + this.client.execute('/interface/print', { detail: '' }).catch(() => []), + this.client.execute('/interface/bridge/vlan/print', { detail: '' }).catch(() => []), + this.client.execute('/ip/route/print', { detail: '' }).catch(() => []), + this.client.execute('/ip/firewall/filter/print', { detail: '' }).catch(() => []), + this.client.execute('/ip/dhcp-server/print', { detail: '' }).catch(() => []), + this.client.execute('/ip/dns/print', { detail: '' }).catch(() => []), + ]); + + const config = { interfaces, vlans, routes, firewall, dhcp, dns }; + + await query( + `INSERT INTO device_configs (device_id, config_json) VALUES ($1, $2)`, + [this.device.id, JSON.stringify(config)] + ); + + // Keep only last 10 full config snapshots per device + await query( + `DELETE FROM device_configs WHERE device_id = $1 AND id NOT IN ( + SELECT id FROM device_configs WHERE device_id = $1 ORDER BY collected_at DESC LIMIT 10 + )`, + [this.device.id] + ); + } catch (err) { + console.error(`[${this.device.name}] Failed to save config:`, err); + } + } + + async updateDeviceStatus(status: string): Promise { + await query( + `UPDATE devices SET status = $1, last_seen = NOW(), updated_at = NOW() WHERE id = $2`, + [status, this.device.id] + ); + } + + // ─── Wifi package detection ─────────────────────────────────────────────── + + private wifiPackageCache: 'wifi' | 'wireless' | 'none' | null = null; + + async detectWifiPackage(): Promise<'wifi' | 'wireless' | 'none'> { + if (this.wifiPackageCache !== null) return this.wifiPackageCache; + // Try RouterOS 7 "wifi" package first (Wi-Fi 6/7 hardware — wlan names are wifi1, wifi2) + try { + await this.client.execute('/interface/wifi/print'); + this.wifiPackageCache = 'wifi'; + return 'wifi'; + } catch { /* fall through */ } + // Try legacy "wireless" package (RouterOS 6.x / older 7.x hardware) + try { + await this.client.execute('/interface/wireless/print'); + this.wifiPackageCache = 'wireless'; + return 'wireless'; + } catch { /* fall through */ } + this.wifiPackageCache = 'none'; + return 'none'; + } + + // Normalize RouterOS 7 wifi package dot-notation fields to flat keys + private normalizeWifiInterface(raw: Record): Record { + const out: Record = { ...raw }; + if (raw['configuration.ssid'] !== undefined) out['ssid'] = raw['configuration.ssid']; + if (raw['configuration.mode'] !== undefined) out['mode'] = raw['configuration.mode']; + if (raw['channel.band'] !== undefined) out['band'] = raw['channel.band']; + if (raw['channel.frequency'] !== undefined) out['frequency'] = raw['channel.frequency']; + if (raw['channel.width'] !== undefined) out['channel-width'] = raw['channel.width']; + if (raw['security.authentication-types'] !== undefined) out['authentication-types'] = raw['security.authentication-types']; + if (raw['security.passphrase'] !== undefined) out['passphrase'] = raw['security.passphrase']; + if (raw['security.encryption'] !== undefined) out['encryption'] = raw['security.encryption']; + // "inactive" field: true = radio is NOT running + if (raw['inactive'] !== undefined && out['running'] === undefined) { + out['running'] = raw['inactive'] === 'false' ? 'true' : 'false'; + } + return out; + } + + // ─── Wireless Interfaces → Postgres ─────────────────────────────────────── + + async collectWirelessInterfaces(): Promise { + try { + const pkg = await this.detectWifiPackage(); + if (pkg === 'none') return; + + const rawList = pkg === 'wifi' + ? await this.client.execute('/interface/wifi/print').catch(() => [] as Record[]) + : await this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record[]); + + const wlans = pkg === 'wifi' ? rawList.map(r => this.normalizeWifiInterface(r)) : rawList; + if (wlans.length === 0) return; + + for (const wlan of wlans) { + const name = wlan['name']; + if (!name) continue; + const freq = parseInt(wlan['frequency'] || '0', 10); + const txPow = parseInt(wlan['tx-power'] || '0', 10); + const gain = parseInt(wlan['antenna-gain'] || '0', 10); + + await query( + `INSERT INTO wireless_interfaces + (device_id, name, ssid, mode, band, frequency, channel_width, tx_power, + tx_power_mode, antenna_gain, country, installation, disabled, running, + mac_address, security_profile, config_json, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW()) + ON CONFLICT (device_id, name) DO UPDATE SET + ssid=$3, mode=$4, band=$5, frequency=$6, channel_width=$7, tx_power=$8, + tx_power_mode=$9, antenna_gain=$10, country=$11, installation=$12, + disabled=$13, running=$14, mac_address=$15, security_profile=$16, + config_json=$17, updated_at=NOW()`, + [ + this.device.id, name, + wlan['ssid'] || null, + wlan['mode'] || null, + wlan['band'] || null, + !isNaN(freq) && freq > 0 ? freq : null, + wlan['channel-width'] || null, + !isNaN(txPow) && txPow > 0 ? txPow : null, + wlan['tx-power-mode'] || null, + !isNaN(gain) ? gain : null, + wlan['country'] || null, + wlan['installation'] || 'indoor', + wlan['disabled'] === 'true', + wlan['running'] === 'true', + wlan['mac-address'] || null, + wlan['security-profile'] || null, + JSON.stringify(wlan), + ] + ); + } + } catch (err) { + console.error(`[${this.device.name}] Failed to collect wireless interfaces:`, err); + } + } + + // ─── Wireless Stats → InfluxDB ──────────────────────────────────────────── + + async collectWirelessStats(): Promise { + try { + const pkg = await this.detectWifiPackage(); + if (pkg === 'none') return; + + const [rawList, regTable] = await Promise.all([ + pkg === 'wifi' + ? this.client.execute('/interface/wifi/print').catch(() => [] as Record[]) + : this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record[]), + pkg === 'wifi' + ? this.client.execute('/interface/wifi/registration-table/print').catch(() => [] as Record[]) + : this.client.execute('/interface/wireless/registration-table/print').catch(() => [] as Record[]), + ]); + + const wlans = pkg === 'wifi' ? rawList.map(r => this.normalizeWifiInterface(r)) : rawList; + if (wlans.length === 0) return; + + const clientsByIface: Record = {}; + for (const r of regTable) { + const iface = r['interface']; + if (iface) clientsByIface[iface] = (clientsByIface[iface] || 0) + 1; + } + + const writeApi = getWriteApi(); + for (const wlan of wlans) { + const name = wlan['name']; + if (!name || wlan['disabled'] === 'true') continue; + const clientCount = clientsByIface[name] || 0; + const noiseFloor = parseInt(wlan['noise-floor'] || '0', 10); + + const point = new Point('wireless_stats') + .tag('device_id', String(this.device.id)) + .tag('device_name', this.device.name) + .tag('interface', name) + .tag('ssid', wlan['ssid'] || name) + .intField('registered_clients', clientCount) + .timestamp(new Date()); + + if (!isNaN(noiseFloor) && noiseFloor !== 0) { + point.intField('noise_floor', noiseFloor); + } + writeApi.writePoint(point); + + await query( + `UPDATE wireless_interfaces SET registered_clients=$1, updated_at=NOW() + WHERE device_id=$2 AND name=$3`, + [clientCount, this.device.id, name] + ); + } + await writeApi.flush().catch((e) => console.error('InfluxDB flush error:', e)); + } catch (err) { + console.error(`[${this.device.name}] Failed to collect wireless stats:`, err); + } + } + + // ─── Security Profiles → Postgres ───────────────────────────────────────── + + async collectSecurityProfiles(): Promise { + try { + const pkg = await this.detectWifiPackage(); + if (pkg === 'none') return; + + if (pkg === 'wifi') { + // Try named /interface/wifi/security profiles first. + // Fall back to synthesizing from interface inline security.* fields if none exist. + const named = await this.client.execute('/interface/wifi/security/print').catch(() => [] as Record[]); + const sourceList: Array<{ name: string; raw: Record }> = named.length > 0 + ? named.map(p => ({ name: p['name'], raw: p })) + : (await this.client.execute('/interface/wifi/print').catch(() => [] as Record[])) + .filter(r => r['security.authentication-types'] || r['security.passphrase']) + .map(r => ({ + name: r['name'], + raw: { + 'authentication-types': r['security.authentication-types'] || '', + passphrase: r['security.passphrase'] || '', + encryption: r['security.encryption'] || '', + }, + })); + + for (const { name, raw } of sourceList) { + if (!name) continue; + const authTypes = (raw['authentication-types'] || '').split(',').filter(Boolean); + await query( + `INSERT INTO wireless_security_profiles + (device_id, name, mode, authentication_types, unicast_ciphers, group_ciphers, + management_protection, config_json, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW()) + ON CONFLICT (device_id, name) DO UPDATE SET + mode=$3, authentication_types=$4, unicast_ciphers=$5, group_ciphers=$6, + management_protection=$7, config_json=$8, updated_at=NOW()`, + [ + this.device.id, name, + 'dynamic-keys', authTypes, [], [], 'disabled', + JSON.stringify(raw), + ] + ); + } + return; + } + + // Legacy wireless package + const profiles = await this.client + .execute('/interface/wireless/security-profiles/print') + .catch(() => [] as Record[]); + + for (const p of profiles) { + const name = p['name']; + if (!name) continue; + const authTypes = (p['authentication-types'] || '').split(',').filter(Boolean); + const unicastCiphers = (p['unicast-ciphers'] || '').split(',').filter(Boolean); + const groupCiphers = (p['group-ciphers'] || '').split(',').filter(Boolean); + + await query( + `INSERT INTO wireless_security_profiles + (device_id, name, mode, authentication_types, unicast_ciphers, group_ciphers, + management_protection, config_json, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW()) + ON CONFLICT (device_id, name) DO UPDATE SET + mode=$3, authentication_types=$4, unicast_ciphers=$5, group_ciphers=$6, + management_protection=$7, config_json=$8, updated_at=NOW()`, + [ + this.device.id, name, + p['mode'] || 'none', + authTypes, unicastCiphers, groupCiphers, + p['management-protection'] || 'disabled', + JSON.stringify(p), + ] + ); + } + } catch (err) { + console.error(`[${this.device.name}] Failed to collect security profiles:`, err); + } + } + + // ─── Wireless live-query helpers (for routes) ────────────────────────────── + + // Field map for translating old wireless-style flat keys → new wifi dot-notation + private static readonly WIFI_FIELD_MAP: Record = { + 'ssid': 'configuration.ssid', + 'mode': 'configuration.mode', + 'band': 'channel.band', + 'frequency': 'channel.frequency', + 'channel-width': 'channel.width', + 'passphrase': 'security.passphrase', + 'wpa2-pre-shared-key': 'security.passphrase', + 'authentication-types': 'security.authentication-types', + 'encryption': 'security.encryption', + 'security-profile': 'security', // old pkg "security-profile" → new pkg "security" + 'disabled': 'disabled', + 'master-interface': 'master-interface', + 'name': 'name', + }; + + // Params valid only in the legacy wireless package — silently dropped for new wifi package + private static readonly WIFI_UNSUPPORTED_PARAMS = new Set([ + 'tx-power-mode', 'tx-power', 'antenna-gain', + 'country', 'installation', + 'wpa-pre-shared-key', 'unicast-ciphers', 'group-ciphers', 'management-protection', + ]); + + private translateToWifiParams(params: Record): Record { + const out: Record = {}; + // Virtual APs (master-interface set) inherit mode from their master — RouterOS rejects + // configuration.mode if it is sent explicitly for a virtual interface. + const isVirtualAp = !!params['master-interface']; + for (const [k, v] of Object.entries(params)) { + if (DeviceCollector.WIFI_UNSUPPORTED_PARAMS.has(k)) continue; + if (k === 'mode' && isVirtualAp) continue; + const mapped = DeviceCollector.WIFI_FIELD_MAP[k]; + out[mapped ?? k] = v; + } + return out; + } + + // ─── Bridge helpers ─────────────────────────────────────────────────────── + + async getBridges(): Promise[]> { + return this.client.execute('/interface/bridge/print').catch(() => []); + } + + async getBridgePorts(): Promise[]> { + return this.client.execute('/interface/bridge/port/print').catch(() => []); + } + + /** + * Add, update, or remove a bridge port membership for a wifi interface. + * Pass bridge=null to remove the interface from any bridge it's in. + */ + async setInterfaceBridge( + ifaceName: string, + bridge: string | null, + pvid?: number, + ): Promise { + const ports = await this.getBridgePorts(); + const existing = ports.find(p => p['interface'] === ifaceName); + + if (!bridge) { + if (existing) { + await this.client.execute('/interface/bridge/port/remove', { '.id': existing['.id'] }); + } + return; + } + + const extra: Record = {}; + if (pvid !== undefined && pvid > 0) extra['pvid'] = String(pvid); + + if (existing) { + await this.client.execute('/interface/bridge/port/set', { + '.id': existing['.id'], bridge, ...extra, + }); + } else { + await this.client.execute('/interface/bridge/port/add', { + interface: ifaceName, bridge, ...extra, + }); + } + } + + // ─── Live wireless interface list (enriched with bridge port data) ───────── + + async getWirelessInterfaces(): Promise[]> { + const pkg = await this.detectWifiPackage(); + if (pkg === 'none') return []; + + const [rawIfaces, bridgePorts] = await Promise.all([ + pkg === 'wifi' + ? this.client.execute('/interface/wifi/print').catch(() => [] as Record[]) + : this.client.execute('/interface/wireless/print', { detail: '' }).catch(() => [] as Record[]), + this.getBridgePorts(), + ]); + + const ifaces = pkg === 'wifi' + ? rawIfaces.map(r => this.normalizeWifiInterface(r)) + : rawIfaces; + + // Enrich each interface with its bridge port membership (if any) + const portByIface = new Map(bridgePorts.map(p => [p['interface'], p])); + return ifaces.map(iface => { + const port = portByIface.get(iface['name']); + if (!port) return iface; + return { + ...iface, + bridge: port['bridge'] || '', + 'bridge-pvid': port['pvid'] || '', + 'bridge-port-id': port['.id'] || '', + }; + }); + } + + /** + * Returns the next available interface name (e.g. wifi3, wifi4 …) by + * fetching the live list from the device and skipping any names already in use. + * Uses the correct prefix for the package in use (wifi vs wlan). + */ + async getNextInterfaceName(): Promise { + const pkg = await this.detectWifiPackage(); + const prefix = pkg === 'wifi' ? 'wifi' : 'wlan'; + const raw = pkg === 'wifi' + ? await this.client.execute('/interface/wifi/print').catch(() => [] as Record[]) + : await this.client.execute('/interface/wireless/print').catch(() => [] as Record[]); + const existing = new Set(raw.map(i => i['name'] || '')); + let idx = 1; + while (existing.has(`${prefix}${idx}`)) idx++; + return `${prefix}${idx}`; + } + + async setWirelessInterface(name: string, params: Record): Promise { + const pkg = await this.detectWifiPackage(); + if (pkg === 'wifi') { + await this.client.execute('/interface/wifi/set', { '.id': name, ...this.translateToWifiParams(params) }); + } else { + // Legacy wireless: drop inline security params — not supported directly on the interface + const { passphrase, 'authentication-types': _at, ...legacyParams } = params; + void passphrase; void _at; + await this.client.execute('/interface/wireless/set', { '.id': name, ...legacyParams }); + } + } + + async addWirelessInterface(params: Record): Promise { + const pkg = await this.detectWifiPackage(); + if (pkg === 'wifi') { + await this.client.execute('/interface/wifi/add', this.translateToWifiParams(params)); + } else { + // Legacy wireless: drop inline security params — not supported directly on the interface + const { passphrase, 'authentication-types': _at, ...legacyParams } = params; + void passphrase; void _at; + await this.client.execute('/interface/wireless/add', legacyParams); + } + } + + async removeWirelessInterface(name: string): Promise { + const pkg = await this.detectWifiPackage(); + const cmd = pkg === 'wifi' ? '/interface/wifi/remove' : '/interface/wireless/remove'; + await this.client.execute(cmd, { '.id': name }); + } + + async getSecurityProfilesLive(): Promise[]> { + const pkg = await this.detectWifiPackage(); + if (pkg === 'wifi') { + // Check for named /interface/wifi/security profiles first. + const named = await this.client.execute('/interface/wifi/security/print').catch(() => [] as Record[]); + if (named.length > 0) { + return named.map(p => ({ + 'name': p['name'], + 'mode': 'dynamic-keys', + 'authentication-types': p['authentication-types'] || '', + 'passphrase': p['passphrase'] || '', + 'encryption': p['encryption'] || '', + 'ft': p['ft'] || 'false', + 'ft-over-ds': p['ft-over-ds'] || 'false', + })); + } + // No named profiles — synthesize from inline interface security.* fields. + const ifaces = await this.client.execute('/interface/wifi/print').catch(() => [] as Record[]); + return ifaces + .filter(r => r['security.authentication-types'] || r['security.passphrase']) + .map(r => ({ + 'name': r['name'], + 'mode': 'dynamic-keys', + 'authentication-types': r['security.authentication-types'] || '', + 'passphrase': r['security.passphrase'] || '', + 'encryption': r['security.encryption'] || '', + 'ft': r['security.ft'] || 'false', + 'ft-over-ds': r['security.ft-over-ds'] || 'false', + })); + } + return this.client.execute('/interface/wireless/security-profiles/print').catch(() => []); + } + + async addSecurityProfile(params: Record): Promise { + const pkg = await this.detectWifiPackage(); + if (pkg === 'wifi') { + const secParams: Record = {}; + if (params['name']) secParams['name'] = params['name']; + if (params['authentication-types']) secParams['authentication-types'] = params['authentication-types']; + if (params['passphrase']) secParams['passphrase'] = params['passphrase']; + if (params['encryption']) secParams['encryption'] = params['encryption']; + await this.client.execute('/interface/wifi/security/add', secParams); + } else { + // Legacy wireless package uses wpa-pre-shared-key / wpa2-pre-shared-key + const legacyParams: Record = { ...params }; + if (params['passphrase']) { + legacyParams['wpa-pre-shared-key'] = params['passphrase']; + legacyParams['wpa2-pre-shared-key'] = params['passphrase']; + delete legacyParams['passphrase']; + } + await this.client.execute('/interface/wireless/security-profiles/add', legacyParams); + } + } + + async setSecurityProfile(name: string, params: Record): Promise { + const pkg = await this.detectWifiPackage(); + if (pkg === 'wifi') { + const secParams: Record = { '.id': name }; + if (params['mode']) secParams['mode'] = params['mode']; + if (params['authentication-types']) secParams['authentication-types'] = params['authentication-types']; + if (params['passphrase']) secParams['passphrase'] = params['passphrase']; + if (params['encryption']) secParams['encryption'] = params['encryption']; + await this.client.execute('/interface/wifi/security/set', secParams); + } else { + const legacyParams: Record = { ...params }; + if (params['passphrase']) { + legacyParams['wpa-pre-shared-key'] = params['passphrase']; + legacyParams['wpa2-pre-shared-key'] = params['passphrase']; + delete legacyParams['passphrase']; + } + await this.client.execute('/interface/wireless/security-profiles/set', { '.id': name, ...legacyParams }); + } + } + + async removeSecurityProfile(name: string): Promise { + const pkg = await this.detectWifiPackage(); + const cmd = pkg === 'wifi' + ? '/interface/wifi/security/remove' + : '/interface/wireless/security-profiles/remove'; + await this.client.execute(cmd, { '.id': name }); + } + + async getWifiRadioInfo(): Promise[]> { + const pkg = await this.detectWifiPackage(); + if (pkg !== 'wifi') return []; + return this.client.execute('/interface/wifi/radio/print').catch(() => []); + } + + async getWirelessRegistrationTable(): Promise[]> { + const pkg = await this.detectWifiPackage(); + const cmd = pkg === 'wifi' + ? '/interface/wifi/registration-table/print' + : '/interface/wireless/registration-table/print'; + return this.client.execute(cmd).catch(() => []); + } + + async getWirelessMonitor(iface: string): Promise[]> { + const pkg = await this.detectWifiPackage(); + const cmd = pkg === 'wifi' ? '/interface/wifi/monitor' : '/interface/wireless/monitor'; + return this.client.execute(cmd, { '.id': iface, once: '' }).catch(() => []); + } + + async scanWireless(iface: string, durationMs = 5_000): Promise[]> { + const pkg = await this.detectWifiPackage(); + const cmd = pkg === 'wifi' ? '/interface/wifi/scan' : '/interface/wireless/scan'; + return this.client.executeStreaming(cmd, { '.id': iface }, durationMs).catch(() => []); + } + + // Run a spectral scan on a wifi interface for ~10 s and return the raw rows. + // Only supported on the new wifi package — returns [] if unavailable. + async collectSpectralScan(iface: string): Promise[]> { + const pkg = await this.detectWifiPackage(); + if (pkg !== 'wifi') return []; + return this.client + .executeStreaming('/interface/wifi/spectral-scan', { '.id': iface }, 10_000) + .catch(() => []); + } + + // ─── Write-back operations ───────────────────────────────────────────────── + + async setInterfaceEnabled(name: string, enabled: boolean): Promise { + const cmd = enabled ? '/interface/enable' : '/interface/disable'; + await this.client.execute(cmd, { numbers: name }); + } + + async setInterfaceComment(name: string, comment: string): Promise { + await this.client.execute('/interface/set', { numbers: name, comment }); + } + + async getDetailedInterface(name: string): Promise | null> { + const results = await this.client.execute('/interface/print', { detail: '' }, [`?name=${name}`]); + return results[0] || null; + } + + async getRoutingTable(): Promise[]> { + return this.client.execute('/ip/route/print', { detail: '' }); + } + + async getFirewallRules(): Promise[]> { + return this.client.execute('/ip/firewall/filter/print', { detail: '' }); + } + + async addFirewallRule(params: Record): Promise { + await this.client.execute('/ip/firewall/filter/add', params); + } + + async updateFirewallRule(id: string, params: Record): Promise { + await this.client.execute('/ip/firewall/filter/set', { '.id': id, ...params }); + } + + async deleteFirewallRule(id: string): Promise { + await this.client.execute('/ip/firewall/filter/remove', { '.id': id }); + } + + async getNatRules(): Promise[]> { + return this.client.execute('/ip/firewall/nat/print', { detail: '' }); + } + + async addNatRule(params: Record): Promise { + await this.client.execute('/ip/firewall/nat/add', params); + } + + async updateNatRule(id: string, params: Record): Promise { + await this.client.execute('/ip/firewall/nat/set', { '.id': id, ...params }); + } + + async deleteNatRule(id: string): Promise { + await this.client.execute('/ip/firewall/nat/remove', { '.id': id }); + } + + // ─── Bridge ──────────────────────────────────────────────────────────────── + + async setBridgeVlanFiltering(bridgeName: string, enabled: boolean): Promise { + const bridges = await this.client.execute('/interface/bridge/print'); + const bridge = bridges.find((b) => b['name'] === bridgeName); + if (!bridge || !bridge['.id']) throw new Error(`Bridge '${bridgeName}' not found`); + await this.client.execute('/interface/bridge/set', { + '.id': bridge['.id'], + 'vlan-filtering': enabled ? 'yes' : 'no', + }); + } + + async getSystemResource(): Promise> { + const res = await this.client.execute('/system/resource/print'); + return res[0] || {}; + } + + async getSystemIdentity(): Promise { + const res = await this.client.execute('/system/identity/print'); + return res[0]?.['name'] || ''; + } + + async setBridgePortPvid(bridge: string, port: string, pvid: number): Promise { + const ports = await this.client.execute('/interface/bridge/port/print', {}, [`?interface=${port}`, `?bridge=${bridge}`]); + if (ports[0]?.['.id']) { + await this.client.execute('/interface/bridge/port/set', { '.id': ports[0]['.id'], pvid: String(pvid) }); + } + } + + // ─── MTU ────────────────────────────────────────────────────────────────── + + async setInterfaceMtu(name: string, mtu: number): Promise { + // Step 1: raise l2mtu so RouterOS doesn't cap the L3 mtu below the requested value. + // This silently fails on logical interfaces (bridge, vlan) where l2mtu is derived — + // that is safe because RouterOSClient now properly drains !done after !trap. + await this.client.execute('/interface/set', { + numbers: name, + 'l2mtu': String(mtu + 8), + }).catch(() => {}); + + // Step 2: set the L3 mtu — always runs regardless of l2mtu outcome above. + await this.client.execute('/interface/set', { numbers: name, mtu: String(mtu) }); + } + + // ─── PoE ────────────────────────────────────────────────────────────────── + + async setPoeOut(name: string, poeOut: 'auto-on' | 'forced-on' | 'off'): Promise { + const ports = await this.client + .execute('/interface/ethernet/poe/print', {}, [`?name=${name}`]) + .catch(() => []); + if (ports[0]?.['.id']) { + await this.client.execute('/interface/ethernet/poe/set', { + '.id': ports[0]['.id'], + 'poe-out': poeOut, + }); + } + } + + async getPoEStatus(name: string): Promise | null> { + const result = await this.client + .execute('/interface/ethernet/poe/print', {}, [`?name=${name}`]) + .catch(() => []); + return result[0] || null; + } + + async getAllPoEStatus(): Promise[]> { + return this.client.execute('/interface/ethernet/poe/print', { detail: '' }).catch(() => []); + } + + // ─── Port VLAN config ───────────────────────────────────────────────────── + + async setPortVlanConfig( + portName: string, + pvid: number, + taggedVlans: number[], + untaggedVlans: number[] + ): Promise { + // 1. Find the bridge port entry and set PVID + const bridgePorts = await this.client + .execute('/interface/bridge/port/print', {}, [`?interface=${portName}`]) + .catch(() => []); + + const bridgePortEntry = bridgePorts[0]; + if (bridgePortEntry?.['.id']) { + await this.client.execute('/interface/bridge/port/set', { + '.id': bridgePortEntry['.id'], + pvid: String(pvid), + }); + } + + const bridge = bridgePortEntry?.['bridge'] || ''; + if (!bridge) return; + + // 2. Update tagged VLANs in bridge VLAN table + for (const vlanId of taggedVlans) { + const existing = await this.client + .execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`]) + .catch(() => []); + + if (existing[0]?.['.id']) { + const currentTagged = this.parseList(existing[0]['tagged'] || ''); + const currentUntagged = this.parseList(existing[0]['untagged'] || ''); + const newTagged = [...new Set([...currentTagged, portName])]; + const newUntagged = currentUntagged.filter((p) => p !== portName); + await this.client.execute('/interface/bridge/vlan/set', { + '.id': existing[0]['.id'], + tagged: newTagged.join(','), + untagged: newUntagged.join(','), + }); + } + } + + // 3. Update untagged VLANs in bridge VLAN table + for (const vlanId of untaggedVlans) { + const existing = await this.client + .execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`]) + .catch(() => []); + + if (existing[0]?.['.id']) { + const currentTagged = this.parseList(existing[0]['tagged'] || ''); + const currentUntagged = this.parseList(existing[0]['untagged'] || ''); + const newUntagged = [...new Set([...currentUntagged, portName])]; + const newTagged = currentTagged.filter((p) => p !== portName); + await this.client.execute('/interface/bridge/vlan/set', { + '.id': existing[0]['.id'], + tagged: newTagged.join(','), + untagged: newUntagged.join(','), + }); + } + } + } + + /** + * Ensure a given interface is an untagged member of `vlanId` in the bridge + * VLAN table. Looks up which bridge the interface belongs to, then either + * updates an existing VLAN entry or creates a new one. + */ + async ensureVlanMembership(interfaceName: string, vlanId: number): Promise { + const bridgePorts = await this.client + .execute('/interface/bridge/port/print', {}, [`?interface=${interfaceName}`]) + .catch(() => []); + const bridgePort = (bridgePorts[0] as Record | undefined); + const bridge = bridgePort?.['bridge']; + if (!bridge) return; // interface not in a bridge yet + + const existing = await this.client + .execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`]) + .catch(() => []); + const entry = (existing[0] as Record | undefined); + + if (entry?.['.id']) { + const currentTagged = this.parseList(entry['tagged'] || ''); + const currentUntagged = this.parseList(entry['untagged'] || ''); + if (!currentUntagged.includes(interfaceName)) { + const newUntagged = [...currentUntagged, interfaceName]; + const newTagged = currentTagged.filter(p => p !== interfaceName); + await this.client.execute('/interface/bridge/vlan/set', { + '.id': entry['.id'], + tagged: newTagged.join(','), + untagged: newUntagged.join(','), + }); + } + } else { + await this.client.execute('/interface/bridge/vlan/add', { + bridge, + 'vlan-ids': String(vlanId), + untagged: interfaceName, + }); + } + } + + // ─── System Config ──────────────────────────────────────────────────────── + + async getSystemConfig(): Promise<{ + identity: string; + ntp: Record; + dns: Record; + }> { + const [identity, ntp, dns] = await Promise.all([ + this.client.execute('/system/identity/print').catch(() => [{}]), + this.client.execute('/system/ntp/client/print').catch(() => [{}]), + this.client.execute('/ip/dns/print').catch(() => [{}]), + ]); + + const ntpConfig: Record = { ...(ntp[0] as Record) }; + + // RouterOS v7 removed primary-ntp/secondary-ntp; servers live in a separate list + if (!ntpConfig['primary-ntp']) { + const servers = await this.client + .execute('/system/ntp/client/servers/print') + .catch(() => []); + if (servers[0]) ntpConfig['primary-ntp'] = (servers[0] as Record)['address'] || ''; + if (servers[1]) ntpConfig['secondary-ntp'] = (servers[1] as Record)['address'] || ''; + } + + return { + identity: (identity[0] as Record)?.['name'] || '', + ntp: ntpConfig, + dns: (dns[0] as Record) || {}, + }; + } + + async setSystemIdentity(name: string): Promise { + await this.client.execute('/system/identity/set', { name }); + } + + async setNtpConfig(enabled: boolean, primaryNtp: string, secondaryNtp: string): Promise { + // Try RouterOS v6 format first (primary-ntp / secondary-ntp as direct properties) + try { + await this.client.execute('/system/ntp/client/set', { + enabled: enabled ? 'yes' : 'no', + 'primary-ntp': primaryNtp, + 'secondary-ntp': secondaryNtp, + }); + return; + } catch { + // RouterOS v7 dropped primary-ntp/secondary-ntp — fall through to server-list approach + } + + // RouterOS v7: enabled flag is separate; servers are managed as a list + await this.client.execute('/system/ntp/client/set', { + enabled: enabled ? 'yes' : 'no', + }); + + // Replace existing server entries + const existing = await this.client + .execute('/system/ntp/client/servers/print') + .catch(() => []); + for (const s of existing) { + await this.client + .execute('/system/ntp/client/servers/remove', { '.id': (s as Record)['.id'] }) + .catch(() => {}); + } + if (primaryNtp) { + await this.client.execute('/system/ntp/client/servers/add', { address: primaryNtp }); + } + if (secondaryNtp) { + await this.client.execute('/system/ntp/client/servers/add', { address: secondaryNtp }); + } + } + + async setDnsConfig(servers: string, allowRemoteRequests: boolean): Promise { + await this.client.execute('/ip/dns/set', { + servers, + 'allow-remote-requests': allowRemoteRequests ? 'yes' : 'no', + }); + } + + // ─── IP Addresses ───────────────────────────────────────────────────────── + + async getIpAddresses(): Promise[]> { + return this.client.execute('/ip/address/print', { detail: '' }).catch(() => []); + } + + async addIpAddress(address: string, interfaceName: string): Promise { + await this.client.execute('/ip/address/add', { address, interface: interfaceName }); + } + + async removeIpAddress(id: string): Promise { + await this.client.execute('/ip/address/remove', { numbers: id }); + } + + // ─── Clock / Time ───────────────────────────────────────────────────────── + + async getClockConfig(): Promise<{ date: string; time: string; timezone: string }> { + const result = await this.client.execute('/system/clock/print').catch(() => [{}]); + const r = (result[0] || {}) as Record; + // RouterOS date format: "mar/25/2026" → "2026-03-25" + const months: Record = { + jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06', + jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12', + }; + let isoDate = ''; + const rosDate = r['date'] || ''; + if (rosDate) { + if (/^\d{4}-\d{2}-\d{2}$/.test(rosDate)) { + // RouterOS 7 may already return ISO format + isoDate = rosDate; + } else { + // RouterOS 6 format: "mar/25/2026" + const parts = rosDate.split('/'); + if (parts.length === 3) { + const [mon, day, year] = parts; + const monthNum = months[mon?.toLowerCase()]; + if (monthNum && day && year) { + isoDate = `${year}-${monthNum}-${day.padStart(2, '0')}`; + } + } + } + } + return { + date: isoDate, + time: (r['time'] || '').substring(0, 5), // "HH:MM:SS" → "HH:MM" + timezone: r['time-zone-name'] || 'UTC', + }; + } + + async setClockConfig(params: { date?: string; time?: string; timezone?: string }): Promise { + const months: Record = { + '01': 'jan', '02': 'feb', '03': 'mar', '04': 'apr', '05': 'may', '06': 'jun', + '07': 'jul', '08': 'aug', '09': 'sep', '10': 'oct', '11': 'nov', '12': 'dec', + }; + const args: Record = {}; + if (params.date) { + // "2026-03-25" → "mar/25/2026" + const [year, month, day] = params.date.split('-'); + args['date'] = `${months[month] || 'jan'}/${day}/${year}`; + } + if (params.time) args['time'] = params.time.length === 5 ? `${params.time}:00` : params.time; + if (params.timezone) args['time-zone-name'] = params.timezone; + if (Object.keys(args).length) { + await this.client.execute('/system/clock/set', args); + } + } + + // ─── Route management ───────────────────────────────────────────────────── + + async addRoute(dstAddress: string, gateway: string, distance?: number, comment?: string): Promise { + const params: Record = { 'dst-address': dstAddress, gateway }; + if (distance !== undefined) params['distance'] = String(distance); + if (comment) params['comment'] = comment; + await this.client.execute('/ip/route/add', params); + } + + async removeRoute(routeId: string): Promise { + await this.client.execute('/ip/route/remove', { numbers: routeId }); + } + + // ─── VLAN management ────────────────────────────────────────────────────── + + async addBridgeVlan(bridge: string, vlanId: number, taggedPorts: string[], untaggedPorts: string[]): Promise { + const params: Record = { bridge, 'vlan-ids': String(vlanId) }; + if (taggedPorts.length) params['tagged'] = taggedPorts.join(','); + if (untaggedPorts.length) params['untagged'] = untaggedPorts.join(','); + await this.client.execute('/interface/bridge/vlan/add', params); + } + + async updateBridgeVlan(bridge: string, vlanId: number, taggedPorts: string[], untaggedPorts: string[]): Promise { + await this.removeBridgeVlan(bridge, vlanId); + await this.addBridgeVlan(bridge, vlanId, taggedPorts, untaggedPorts); + } + + async removeBridgeVlan(bridge: string, vlanId: number): Promise { + const entries = await this.client + .execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`]) + .catch(() => []); + for (const entry of entries) { + const id = (entry as Record)['.id']; + if (id) await this.client.execute('/interface/bridge/vlan/remove', { numbers: id }).catch(() => {}); + } + } + + // ─── Bond (LAG / LACP) management ──────────────────────────────────────── + + // RouterOS uses '1sec' / '30sec' for lacp-rate, not 'fast' / 'slow' + private mapLacpRate(rate: string): string { + if (rate === 'fast') return '1sec'; + if (rate === 'slow') return '30sec'; + return rate; + } + + // RouterOS uses 'layer-2', 'layer-2-and-3', 'layer-3-and-4' + private mapHashPolicy(policy: string): string { + if (policy === 'layer2') return 'layer-2'; + if (policy === 'layer2+3') return 'layer-2-and-3'; + if (policy === 'layer3+4') return 'layer-3-and-4'; + return policy; + } + + async createBond(name: string, slaves: string[], mode: string, opts: { + lacpRate?: string; hashPolicy?: string; mtu?: number; minLinks?: number; + }): Promise { + // Remove each slave from any bridge, recording the first bridge found + let originalBridge: string | null = null; + for (const slave of slaves) { + const bridgePorts = await this.client.execute('/interface/bridge/port/print', {}, [`?interface=${slave}`]).catch(() => []); + for (const bp of bridgePorts) { + if (bp['.id']) { + if (!originalBridge) originalBridge = bp['bridge'] ?? null; + await this.client.execute('/interface/bridge/port/remove', { '.id': bp['.id'] }); + } + } + } + const params: Record = { name, slaves: slaves.join(','), mode }; + if (opts.lacpRate) params['lacp-rate'] = this.mapLacpRate(opts.lacpRate); + if (opts.hashPolicy) params['transmit-hash-policy'] = this.mapHashPolicy(opts.hashPolicy); + if (opts.mtu) params['mtu'] = String(opts.mtu); + if (opts.minLinks != null) params['min-links'] = String(opts.minLinks); + await this.client.execute('/interface/bonding/add', params); + // Add the new bond interface to the same bridge the slaves were removed from + if (originalBridge) { + await this.client.execute('/interface/bridge/port/add', { + bridge: originalBridge, + interface: name, + }).catch(() => {}); + } + } + + async updateBond(name: string, slaves: string[], mode: string, opts: { + lacpRate?: string; hashPolicy?: string; mtu?: number; minLinks?: number; + }): Promise { + const list = await this.client.execute('/interface/bonding/print', {}, [`?name=${name}`]); + const id = list[0]?.['.id']; + if (!id) throw new Error(`Bond '${name}' not found on device`); + const params: Record = { '.id': id, slaves: slaves.join(','), mode }; + if (opts.lacpRate) params['lacp-rate'] = this.mapLacpRate(opts.lacpRate); + if (opts.hashPolicy) params['transmit-hash-policy'] = this.mapHashPolicy(opts.hashPolicy); + if (opts.mtu) params['mtu'] = String(opts.mtu); + if (opts.minLinks != null) params['min-links'] = String(opts.minLinks); + await this.client.execute('/interface/bonding/set', params); + } + + async deleteBond(name: string): Promise { + const list = await this.client.execute('/interface/bonding/print', {}, [`?name=${name}`]); + const id = list[0]?.['.id']; + if (!id) throw new Error(`Bond '${name}' not found on device`); + const slaves = (list[0]?.['slaves'] ?? '').split(',').map((s: string) => s.trim()).filter(Boolean); + // Check if the bond is itself a bridge member + const bondBridgePorts = await this.client.execute('/interface/bridge/port/print', {}, [`?interface=${name}`]).catch(() => []); + const bridgeName = bondBridgePorts[0]?.['bridge'] ?? null; + if (bondBridgePorts[0]?.['.id']) { + await this.client.execute('/interface/bridge/port/remove', { '.id': bondBridgePorts[0]['.id'] }); + } + await this.client.execute('/interface/bonding/remove', { '.id': id }); + // Re-add each slave to the bridge the bond was in + if (bridgeName) { + for (const slave of slaves) { + await this.client.execute('/interface/bridge/port/add', { + bridge: bridgeName, + interface: slave, + }).catch(() => {}); + } + } + } + + // ─── Hardware Health ────────────────────────────────────────────────────── + + async getHardware(): Promise<{ + health: Record[]; + disks: Record[]; + }> { + const [health, externalDisks, resource] = await Promise.all([ + this.client.execute('/system/health/print').catch(() => []), + this.client.execute('/disk/print').catch(() => []), + this.client.execute('/system/resource/print').catch(() => []), + ]); + + const disks: Record[] = []; + + // Internal flash storage from system resource (values are raw bytes) + const res = (resource[0] || {}) as Record; + const hddTotal = res['hdd-total'] || res['total-hdd-space'] || ''; + const hddFree = res['hdd-free'] || res['free-hdd-space'] || ''; + if (hddTotal) { + disks.push({ + name: 'flash', + label: 'Internal Flash', + type: 'flash', + total: hddTotal, + free: hddFree, + }); + } + + // External / additional disks (values are human-readable, e.g. "7.5GiB") + for (const d of externalDisks) { + disks.push(d as Record); + } + + return { health, disks }; + } + + // ─── Firmware updates ───────────────────────────────────────────────────── + + async checkForUpdates(): Promise> { + await this.client.execute('/system/package/update/check-for-updates').catch(() => {}); + // Give the device a moment to complete the check + await new Promise((resolve) => setTimeout(resolve, 3000)); + const result = await this.client + .execute('/system/package/update/print') + .catch(() => [] as Record[]); + return (result[0] as Record) || {}; + } + + async installUpdate(): Promise { + await this.client.execute('/system/package/update/install'); + } + + async reboot(): Promise { + await this.client.execute('/system/reboot'); + } + + // ─── Ethernet monitor / SFP DDM ─────────────────────────────────────────── + + async getPortMonitor(name: string): Promise> { + const results = await this.client + .execute('/interface/ethernet/monitor', { numbers: name, once: '' }) + .catch(() => []); + return (results[0] as Record) || {}; + } + + async setFecMode(name: string, fecMode: string): Promise { + await this.client.execute('/interface/ethernet/set', { numbers: name, 'fec-mode': fecMode }); + } + + async setFlowControl(name: string, txFc: string, rxFc: string): Promise { + await this.client.execute('/interface/ethernet/set', { + numbers: name, + 'tx-flow-control': txFc, + 'rx-flow-control': rxFc, + }); + } + + async setAutoNegotiation(name: string, autoNeg: boolean, speed?: string): Promise { + const params: Record = { + numbers: name, + 'auto-negotiation': autoNeg ? 'yes' : 'no', + }; + if (!autoNeg && speed) params['speed'] = speed; + await this.client.execute('/interface/ethernet/set', params); + } + + // ─── Routing Protocols ──────────────────────────────────────────────────── + + async getOspfData(): Promise<{ + instances: Record[]; + areas: Record[]; + interfaceTemplates: Record[]; + neighbors: Record[]; + }> { + const [instances, areas, interfaceTemplates, neighbors] = await Promise.all([ + this.client.execute('/routing/ospf/instance/print', { detail: '' }).catch(() => [] as Record[]), + this.client.execute('/routing/ospf/area/print', { detail: '' }).catch(() => [] as Record[]), + // ROS7 uses interface-template, ROS6 uses interface + this.client.execute('/routing/ospf/interface-template/print', { detail: '' }) + .catch(() => this.client.execute('/routing/ospf/interface/print', { detail: '' }).catch(() => [] as Record[])), + this.client.execute('/routing/ospf/neighbor/print', { detail: '' }).catch(() => [] as Record[]), + ]); + return { instances, areas, interfaceTemplates, neighbors }; + } + + async addOspfInstance(params: Record): Promise { + await this.client.execute('/routing/ospf/instance/add', params); + } + + async removeOspfInstance(id: string): Promise { + await this.client.execute('/routing/ospf/instance/remove', { '.id': id }); + } + + async addOspfArea(params: Record): Promise { + await this.client.execute('/routing/ospf/area/add', params); + } + + async removeOspfArea(id: string): Promise { + await this.client.execute('/routing/ospf/area/remove', { '.id': id }); + } + + async getBgpData(): Promise<{ + connections: Record[]; + sessions: Record[]; + templates: Record[]; + }> { + // ROS7 uses /routing/bgp/connection, ROS6 uses /routing/bgp/peer + const [connections, sessions, templates] = await Promise.all([ + this.client.execute('/routing/bgp/connection/print', { detail: '' }) + .catch(() => this.client.execute('/routing/bgp/peer/print', { detail: '' }).catch(() => [] as Record[])), + this.client.execute('/routing/bgp/session/print', { detail: '' }).catch(() => [] as Record[]), + this.client.execute('/routing/bgp/template/print', { detail: '' }) + .catch(() => this.client.execute('/routing/bgp/instance/print', { detail: '' }).catch(() => [] as Record[])), + ]); + return { connections, sessions, templates }; + } + + async addBgpConnection(params: Record): Promise { + // Try ROS7 path first, then ROS6 + await this.client.execute('/routing/bgp/connection/add', params) + .catch(() => this.client.execute('/routing/bgp/peer/add', params)); + } + + async removeBgpConnection(id: string): Promise { + await this.client.execute('/routing/bgp/connection/remove', { '.id': id }) + .catch(() => this.client.execute('/routing/bgp/peer/remove', { '.id': id })); + } + + async getRoutingTablesData(): Promise[]> { + return this.client.execute('/routing/table/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addRoutingTable(params: Record): Promise { + await this.client.execute('/routing/table/add', params); + } + + async removeRoutingTable(id: string): Promise { + await this.client.execute('/routing/table/remove', { '.id': id }); + } + + async getRouteFiltersData(): Promise<{ + rules: Record[]; + chains: string[]; + }> { + // ROS7 uses /routing/filter/rule, ROS6 uses /routing/filter + const rules = await this.client.execute('/routing/filter/rule/print', { detail: '' }) + .catch(() => this.client.execute('/routing/filter/print', { detail: '' }).catch(() => [] as Record[])); + const chains = [...new Set(rules.map(r => r['chain']).filter(Boolean))]; + return { rules, chains }; + } + + async addFilterRule(params: Record): Promise { + await this.client.execute('/routing/filter/rule/add', params) + .catch(() => this.client.execute('/routing/filter/add', params)); + } + + async updateFilterRule(id: string, params: Record): Promise { + await this.client.execute('/routing/filter/rule/set', { '.id': id, ...params }) + .catch(() => this.client.execute('/routing/filter/set', { '.id': id, ...params })); + } + + async removeFilterRule(id: string): Promise { + await this.client.execute('/routing/filter/rule/remove', { '.id': id }) + .catch(() => this.client.execute('/routing/filter/remove', { '.id': id })); + } + + async getRouterIds(): Promise[]> { + return this.client.execute('/routing/id/print', { detail: '' }).catch(() => [] as Record[]); + } + + // ─── Network Services ───────────────────────────────────────────────────────── + + // DHCP ──────────────────────────────────────────────────────────────────────── + + async getDhcpServers(protocol: 'ipv4' | 'ipv6'): Promise[]> { + const cmd = protocol === 'ipv4' ? '/ip/dhcp-server/print' : '/ipv6/dhcp-server/print'; + return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record[]); + } + + async addDhcpServer(params: Record, protocol: 'ipv4' | 'ipv6'): Promise { + const base = protocol === 'ipv4' ? '/ip/dhcp-server' : '/ipv6/dhcp-server'; + await this.client.execute(`${base}/add`, params); + } + + async updateDhcpServer(id: string, params: Record, protocol: 'ipv4' | 'ipv6'): Promise { + const base = protocol === 'ipv4' ? '/ip/dhcp-server' : '/ipv6/dhcp-server'; + await this.client.execute(`${base}/set`, { '.id': id, ...params }); + } + + async removeDhcpServer(id: string, protocol: 'ipv4' | 'ipv6'): Promise { + const base = protocol === 'ipv4' ? '/ip/dhcp-server' : '/ipv6/dhcp-server'; + await this.client.execute(`${base}/remove`, { '.id': id }); + } + + async setDhcpServerDisabled(id: string, disabled: boolean, protocol: 'ipv4' | 'ipv6'): Promise { + const base = protocol === 'ipv4' ? '/ip/dhcp-server' : '/ipv6/dhcp-server'; + const cmd = disabled ? `${base}/disable` : `${base}/enable`; + await this.client.execute(cmd, { '.id': id }); + } + + async getDhcpPools(protocol: 'ipv4' | 'ipv6'): Promise[]> { + const cmd = protocol === 'ipv4' ? '/ip/pool/print' : '/ipv6/pool/print'; + return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record[]); + } + + async addDhcpPool(params: Record, protocol: 'ipv4' | 'ipv6'): Promise { + const base = protocol === 'ipv4' ? '/ip/pool' : '/ipv6/pool'; + await this.client.execute(`${base}/add`, params); + } + + async removeDhcpPool(id: string, protocol: 'ipv4' | 'ipv6'): Promise { + const base = protocol === 'ipv4' ? '/ip/pool' : '/ipv6/pool'; + await this.client.execute(`${base}/remove`, { '.id': id }); + } + + async getDhcpLeases(protocol: 'ipv4' | 'ipv6'): Promise[]> { + const cmd = protocol === 'ipv4' + ? '/ip/dhcp-server/lease/print' + : '/ipv6/dhcp-server/binding/print'; + return this.client.execute(cmd, { detail: '' }).catch(() => [] as Record[]); + } + + async addStaticDhcpLease(params: Record, protocol: 'ipv4' | 'ipv6'): Promise { + const base = protocol === 'ipv4' ? '/ip/dhcp-server/lease' : '/ipv6/dhcp-server/binding'; + await this.client.execute(`${base}/add`, params); + } + + async removeStaticDhcpLease(id: string, protocol: 'ipv4' | 'ipv6'): Promise { + const base = protocol === 'ipv4' ? '/ip/dhcp-server/lease' : '/ipv6/dhcp-server/binding'; + await this.client.execute(`${base}/remove`, { '.id': id }); + } + + // DNS ───────────────────────────────────────────────────────────────────────── + + async getDnsSettings(): Promise> { + const rows = await this.client.execute('/ip/dns/print', {}).catch(() => [] as Record[]); + return rows[0] ?? {}; + } + + async setDnsSettings(settings: { + servers?: string; + allow_remote_requests?: boolean; + max_udp_packet_size?: string; + cache_size?: string; + cache_max_ttl?: string; + }): Promise { + const params: Record = {}; + if (settings.servers !== undefined) params['servers'] = settings.servers; + if (settings.allow_remote_requests !== undefined) { + params['allow-remote-requests'] = settings.allow_remote_requests ? 'yes' : 'no'; + } + if (settings.max_udp_packet_size) params['max-udp-packet-size'] = settings.max_udp_packet_size; + if (settings.cache_size) params['cache-size'] = settings.cache_size; + if (settings.cache_max_ttl) params['cache-max-ttl'] = settings.cache_max_ttl; + await this.client.execute('/ip/dns/set', params); + } + + async getDnsStaticEntries(): Promise[]> { + return this.client.execute('/ip/dns/static/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addDnsStaticEntry(params: Record): Promise { + await this.client.execute('/ip/dns/static/add', params); + } + + async updateDnsStaticEntry(id: string, params: Record): Promise { + await this.client.execute('/ip/dns/static/set', { '.id': id, ...params }); + } + + async removeDnsStaticEntry(id: string): Promise { + await this.client.execute('/ip/dns/static/remove', { '.id': id }); + } + + async flushDnsCache(): Promise { + await this.client.execute('/ip/dns/cache/flush', {}); + } + + // NTP ───────────────────────────────────────────────────────────────────────── + + async getNtpSettings(): Promise<{ + server: Record; + client: Record; + }> { + const [server, client] = await Promise.allSettled([ + this.client.execute('/system/ntp/server/print', {}).then(r => r[0] ?? {}), + this.client.execute('/system/ntp/client/print', {}).then(r => r[0] ?? {}), + ]); + return { + server: server.status === 'fulfilled' ? server.value as Record : {}, + client: client.status === 'fulfilled' ? client.value as Record : {}, + }; + } + + async setNtpSettings(settings: { + server_enabled?: boolean; + server_broadcast?: boolean; + server_manycast?: boolean; + client_enabled?: boolean; + client_mode?: string; + client_servers?: string; + }): Promise { + const serverParams: Record = {}; + if (settings.server_enabled !== undefined) serverParams['enabled'] = settings.server_enabled ? 'yes' : 'no'; + if (settings.server_broadcast !== undefined) serverParams['broadcast'] = settings.server_broadcast ? 'yes' : 'no'; + if (settings.server_manycast !== undefined) serverParams['manycast'] = settings.server_manycast ? 'yes' : 'no'; + if (Object.keys(serverParams).length > 0) { + await this.client.execute('/system/ntp/server/set', serverParams).catch(() => {}); + } + + const clientParams: Record = {}; + if (settings.client_enabled !== undefined) clientParams['enabled'] = settings.client_enabled ? 'yes' : 'no'; + if (settings.client_mode) clientParams['mode'] = settings.client_mode; + if (settings.client_servers !== undefined) clientParams['servers'] = settings.client_servers; + if (Object.keys(clientParams).length > 0) { + await this.client.execute('/system/ntp/client/set', clientParams).catch(() => {}); + } + } + + // WireGuard ─────────────────────────────────────────────────────────────────── + + async getWireGuardInterfaces(): Promise[]> { + return this.client.execute('/interface/wireguard/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addWireGuardInterface(params: Record): Promise[]> { + await this.client.execute('/interface/wireguard/add', params); + // Return updated list so caller gets the new interface with its generated public key + return this.getWireGuardInterfaces(); + } + + async updateWireGuardInterface(id: string, params: Record): Promise { + await this.client.execute('/interface/wireguard/set', { '.id': id, ...params }); + } + + async removeWireGuardInterface(id: string): Promise { + await this.client.execute('/interface/wireguard/remove', { '.id': id }); + } + + async setWireGuardInterfaceDisabled(id: string, disabled: boolean): Promise { + const cmd = disabled ? '/interface/wireguard/disable' : '/interface/wireguard/enable'; + await this.client.execute(cmd, { '.id': id }); + } + + async getWireGuardPeers(): Promise[]> { + return this.client.execute('/interface/wireguard/peers/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addWireGuardPeer(params: Record): Promise { + await this.client.execute('/interface/wireguard/peers/add', params); + } + + async updateWireGuardPeer(id: string, params: Record): Promise { + await this.client.execute('/interface/wireguard/peers/set', { '.id': id, ...params }); + } + + async removeWireGuardPeer(id: string): Promise { + await this.client.execute('/interface/wireguard/peers/remove', { '.id': id }); + } +} diff --git a/backend/src/services/mikrotik/RouterOSClient.ts b/backend/src/services/mikrotik/RouterOSClient.ts new file mode 100644 index 0000000..87e1b66 --- /dev/null +++ b/backend/src/services/mikrotik/RouterOSClient.ts @@ -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; + 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 = Promise.resolve(); + + // Pending read resolver (one outstanding read at a time) + private pendingRead: { + resolve: (s: RouterOSSentence) => void; + reject: (e: Error) => void; + timeout: ReturnType; + } | 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 { + if (this.connected) return; + + await new Promise((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 = {}, + queries: string[] = [] + ): Promise[]> { + let result!: Record[]; + let error!: Error; + + await new Promise((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, + queries: string[] + ): Promise[]> { + 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[] = []; + 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 { + // 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 { + 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 = {}; + 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 { + // 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 = {}, + maxDurationMs = 30_000 + ): Promise[]> { + let result!: Record[]; + let error!: Error; + + await new Promise((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, + maxDurationMs: number + ): Promise[]> { + 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[] = []; + 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; + } +} diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts new file mode 100644 index 0000000..ba72855 --- /dev/null +++ b/backend/src/utils/crypto.ts @@ -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'); +} diff --git a/backend/src/utils/oui.ts b/backend/src/utils/oui.ts new file mode 100644 index 0000000..a48a935 --- /dev/null +++ b/backend/src/utils/oui.ts @@ -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 | null = null; +let initPromise: Promise | null = null; + +export function initOuiDatabase(): Promise { + 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 { + // 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; + 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 = {}; + 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 { + const map = new Map(); + 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 { + 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')); }); + }); +} diff --git a/backend/src/utils/serverArp.ts b/backend/src/utils/serverArp.ts new file mode 100644 index 0000000..bbbcec7 --- /dev/null +++ b/backend/src/utils/serverArp.ts @@ -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> { + const map: Record = {}; + 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> { + const map: Record = {}; + try { + const { stdout } = await execFileAsync( + 'arp-scan', + ['--localnet', '--quiet', '--retry=2'], + { timeout: 12000 } + ); + for (const line of stdout.split('\n')) { + // Output lines: "\t\t" + 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> { + const [passive, active] = await Promise.all([ + readKernelArpCache(), + runArpScan(), + ]); + // Passive cache takes precedence over scan (it's live kernel state) + return { ...active, ...passive }; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..50572da --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4ff60fb --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ba98a20 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + Mikrotik Manager + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c21dbc6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3752 @@ +{ + "name": "mikrotik-manager-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mikrotik-manager-frontend", + "version": "1.0.0", + "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-leaflet": "^5.0.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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz", + "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", + "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.95.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", + "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "dev": true, + "license": "ISC" + }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.309.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.309.0.tgz", + "integrity": "sha512-zNVPczuwFrCfksZH3zbd1UDE6/WYhYAdbe2k7CImVyPAkXLgIwbs6eXQ4loigqDnUFjyFYCI5jZ1y10Kqal0dg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3dabaed --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..12a8c1e --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..be1dc36 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ; + return <>{children}; +} + +export default function App() { + return ( + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ); +} diff --git a/frontend/src/components/CircuitBackground.tsx b/frontend/src/components/CircuitBackground.tsx new file mode 100644 index 0000000..2eebfbb --- /dev/null +++ b/frontend/src/components/CircuitBackground.tsx @@ -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(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 ( + + ); +} diff --git a/frontend/src/components/TerminalModal.tsx b/frontend/src/components/TerminalModal.tsx new file mode 100644 index 0000000..2da9cdb --- /dev/null +++ b/frontend/src/components/TerminalModal.tsx @@ -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(null); + const windowRef = useRef(null); + const xtermRef = useRef(null); + const fitRef = useRef(null); + const socketRef = useRef(null); + + const [connState, setConnState] = useState('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) => { + 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 */} +
+ + {/* Window */} +
+ {/* Resize handles */} + {!maximized && RESIZE_HANDLES.map(({ dir, cursor, style }) => ( +
handleResizeMouseDown(e, dir)} + /> + ))} + + {/* Title bar — drag target */} +
+ + + SSH — {deviceName} + + + {connState === 'connecting' && ( + + Connecting… + + )} + {connState === 'ready' && ( + + Connected + + )} + {(connState === 'error' || connState === 'closed') && ( + + )} + + + +
+ + {/* Error banner */} + {connState === 'error' && errorMsg && ( +
+ + {errorMsg} +
+ )} + + {/* Terminal */} +
+
+
+
+ , + document.body + ); +} diff --git a/frontend/src/components/device-detail/DeviceLocationSection.tsx b/frontend/src/components/device-detail/DeviceLocationSection.tsx new file mode 100644 index 0000000..cc1a467 --- /dev/null +++ b/frontend/src/components/device-detail/DeviceLocationSection.tsx @@ -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(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: '© OpenStreetMap contributors', + }).addTo(map); + L.circleMarker([latN, lngN], { + radius: 9, color: '#fff', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.9, + }).bindPopup(`${address}`).addTo(map); + map.setView([latN, lngN], 15); + return () => { map.remove(); }; + }, [latN, lngN, address]); + + return ( +
+
+ +

+ {address} +

+ + Open map ↗ + +
+
+
+ ); +} + +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({ + 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 ( +
+ {/* Info / edit card */} +
+
+

Physical Details

+ {canWrite && !editing && ( + + )} +
+ + {editing ? ( +
+
+ + setForm((f) => ({ ...f, location_address: e.target.value }))} + placeholder="123 Main St, City, State 00000" + /> +

Address will be geocoded and shown on the map.

+
+
+
+ + setForm((f) => ({ ...f, rack_name: e.target.value }))} + placeholder="Rack-A1" + /> +
+
+ + setForm((f) => ({ ...f, rack_slot: e.target.value }))} + placeholder="U3" + /> +
+
+
+ +