feature(devices,topology,tz): devices bulk-add, duplicate safety, and firmware/timezone fixes
Squash-merge of PR #28 from @denis-ev. All three review blockers addressed: encryption key management documented, string truncation replaced with skip+warn validation, and undefined return on duplicate merge fixed with proper 500. Local test stack verified against cloned prod DB — migrations, new features, and backend all clean.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
# Host ports (maps to the nginx container)
|
# Host ports (maps to the nginx container)
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
HTTPS_PORT=443
|
HTTPS_PORT=443
|
||||||
|
TZ=UTC
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DB_PASSWORD=mikrotik_secure_pw
|
DB_PASSWORD=mikrotik_secure_pw
|
||||||
|
|||||||
@@ -344,6 +344,19 @@ All configuration is done via environment variables in `.env`:
|
|||||||
| `HTTP_PORT` | `80` | Host port for HTTP (redirects to HTTPS) |
|
| `HTTP_PORT` | `80` | Host port for HTTP (redirects to HTTPS) |
|
||||||
| `HTTPS_PORT` | `443` | Host port for HTTPS |
|
| `HTTPS_PORT` | `443` | Host port for HTTPS |
|
||||||
|
|
||||||
|
### Credential encryption (`ENCRYPTION_KEY`)
|
||||||
|
|
||||||
|
Device API and SSH passwords stored in PostgreSQL are encrypted at rest using **AES-256-GCM** (`backend/src/utils/crypto.ts`). The key material comes from the **`ENCRYPTION_KEY` environment variable** (see table above). If the value is exactly 32 bytes long it is used as-is; otherwise the implementation derives a 32-byte key with SHA-256 (still driven by that env var).
|
||||||
|
|
||||||
|
**If the key is lost:** ciphertext cannot be decrypted; you must re-enter credentials on each device (or restore a database backup that was encrypted with the old key).
|
||||||
|
|
||||||
|
**If the key is rotated (compromise or policy):** there is no automatic bulk re-encryption in the app today. Operational recovery is:
|
||||||
|
|
||||||
|
1. Set a new `ENCRYPTION_KEY` and restart the backend.
|
||||||
|
2. For each managed device, update API/SSH credentials via the UI (or API) so the server stores new ciphertext under the new key. Old rows still hold ciphertext from the previous key until updated.
|
||||||
|
|
||||||
|
For a planned rotation with many devices, restore from backup or script updates against the API using plaintext passwords from your vault.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|||||||
@@ -173,9 +173,19 @@ 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 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 neighbor_caps VARCHAR(255);
|
||||||
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS discovered_by VARCHAR(50);
|
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS discovered_by VARCHAR(50);
|
||||||
|
-- RouterOS can return a long comma-separated discovered-by list; 50 chars was too small.
|
||||||
|
ALTER TABLE topology_links ALTER COLUMN discovered_by TYPE VARCHAR(512);
|
||||||
|
-- Interface names from neighbor discovery can exceed 50 chars (long bridge/bond names).
|
||||||
|
ALTER TABLE topology_links ALTER COLUMN from_interface TYPE VARCHAR(512);
|
||||||
|
ALTER TABLE topology_links ALTER COLUMN to_interface TYPE VARCHAR(512);
|
||||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS firmware_update_available BOOLEAN DEFAULT FALSE;
|
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);
|
ALTER TABLE devices ADD COLUMN IF NOT EXISTS latest_ros_version VARCHAR(20);
|
||||||
|
|
||||||
|
-- Cached IPv4/IPv6 addresses from /ip/address (per device) for topology resolution:
|
||||||
|
-- neighbors seen only by IP (CDP/MNDP) can be matched to managed devices even
|
||||||
|
-- when the address is not the device's management IP.
|
||||||
|
ALTER TABLE devices ADD COLUMN IF NOT EXISTS ip_addresses_jsonb JSONB;
|
||||||
|
|
||||||
-- Allow multiple neighbors per interface (one row per neighbor, not per port)
|
-- 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;
|
ALTER TABLE topology_links DROP CONSTRAINT IF EXISTS topology_links_from_device_id_from_interface_key;
|
||||||
|
|
||||||
@@ -260,6 +270,24 @@ CREATE TABLE IF NOT EXISTS ap_scan_data (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_ap_scan_device
|
CREATE INDEX IF NOT EXISTS idx_ap_scan_device
|
||||||
ON ap_scan_data(device_id, scanned_at DESC);
|
ON ap_scan_data(device_id, scanned_at DESC);
|
||||||
|
|
||||||
|
-- Device credential presets — reusable API/SSH credential sets, referenced
|
||||||
|
-- by name when adding or editing a managed device. Passwords are stored
|
||||||
|
-- encrypted at rest (same scheme as devices.api_password_encrypted) so the
|
||||||
|
-- plaintext never leaves the backend.
|
||||||
|
CREATE TABLE IF NOT EXISTS credential_presets (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
api_username VARCHAR(50) NOT NULL,
|
||||||
|
api_password_encrypted TEXT NOT NULL,
|
||||||
|
api_port INTEGER,
|
||||||
|
ssh_username VARCHAR(50),
|
||||||
|
ssh_password_encrypted TEXT,
|
||||||
|
ssh_port INTEGER,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
-- Wireless security profiles (WPA/WPA2/WPA3 config)
|
-- Wireless security profiles (WPA/WPA2/WPA3 config)
|
||||||
CREATE TABLE IF NOT EXISTS wireless_security_profiles (
|
CREATE TABLE IF NOT EXISTS wireless_security_profiles (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import routersRoutes from './routes/routers';
|
|||||||
import alertsRoutes from './routes/alerts';
|
import alertsRoutes from './routes/alerts';
|
||||||
import wirelessRoutes from './routes/wireless';
|
import wirelessRoutes from './routes/wireless';
|
||||||
import networkServicesRoutes from './routes/networkServices';
|
import networkServicesRoutes from './routes/networkServices';
|
||||||
|
import credentialPresetsRoutes from './routes/credentialPresets';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
@@ -197,6 +198,7 @@ app.use('/api/routers', routersRoutes);
|
|||||||
app.use('/api/alerts', alertsRoutes);
|
app.use('/api/alerts', alertsRoutes);
|
||||||
app.use('/api/wireless', wirelessRoutes);
|
app.use('/api/wireless', wirelessRoutes);
|
||||||
app.use('/api/network-services', networkServicesRoutes);
|
app.use('/api/network-services', networkServicesRoutes);
|
||||||
|
app.use('/api/credential-presets', credentialPresetsRoutes);
|
||||||
|
|
||||||
// ─── Error Handler ────────────────────────────────────────────────────────────
|
// ─── Error Handler ────────────────────────────────────────────────────────────
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { query, queryOne } from '../config/database';
|
||||||
|
import { requireAuth, requireAdmin } from '../middleware/auth';
|
||||||
|
import { encrypt } from '../utils/crypto';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
export interface CredentialPresetRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
api_username: string;
|
||||||
|
api_password_encrypted: string;
|
||||||
|
api_port: number | null;
|
||||||
|
ssh_username: string | null;
|
||||||
|
ssh_password_encrypted: string | null;
|
||||||
|
ssh_port: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public (listable) shape — never returns the encrypted secrets themselves,
|
||||||
|
// just booleans telling the UI which slots are populated.
|
||||||
|
interface CredentialPresetPublic {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
api_username: string;
|
||||||
|
api_port: number | null;
|
||||||
|
ssh_username: string | null;
|
||||||
|
ssh_port: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
has_api_password: boolean;
|
||||||
|
has_ssh_password: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPublic(row: CredentialPresetRow): CredentialPresetPublic {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
api_username: row.api_username,
|
||||||
|
api_port: row.api_port,
|
||||||
|
ssh_username: row.ssh_username,
|
||||||
|
ssh_port: row.ssh_port,
|
||||||
|
notes: row.notes,
|
||||||
|
has_api_password: !!row.api_password_encrypted,
|
||||||
|
has_ssh_password: !!row.ssh_password_encrypted,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/credential-presets — any authenticated user can list (the Add
|
||||||
|
// Device modal needs this for its picker), but secrets are never exposed.
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
const rows = await query<CredentialPresetRow>(
|
||||||
|
`SELECT * FROM credential_presets ORDER BY name ASC`
|
||||||
|
);
|
||||||
|
res.json(rows.map(toPublic));
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/credential-presets — admin only
|
||||||
|
router.post('/', requireAdmin, async (req: Request, res: Response) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
api_username,
|
||||||
|
api_password,
|
||||||
|
api_port,
|
||||||
|
ssh_username,
|
||||||
|
ssh_password,
|
||||||
|
ssh_port,
|
||||||
|
notes,
|
||||||
|
} = req.body as {
|
||||||
|
name?: string;
|
||||||
|
api_username?: string;
|
||||||
|
api_password?: string;
|
||||||
|
api_port?: number | null;
|
||||||
|
ssh_username?: string | null;
|
||||||
|
ssh_password?: string | null;
|
||||||
|
ssh_port?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!name || !api_username || !api_password) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'name, api_username, and api_password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne<{ id: number }>(
|
||||||
|
`SELECT id FROM credential_presets WHERE name = $1`,
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ error: 'A preset with this name already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encApi = encrypt(api_password);
|
||||||
|
const encSsh = ssh_password ? encrypt(ssh_password) : null;
|
||||||
|
|
||||||
|
const rows = await query<CredentialPresetRow>(
|
||||||
|
`INSERT INTO credential_presets
|
||||||
|
(name, api_username, api_password_encrypted, api_port,
|
||||||
|
ssh_username, ssh_password_encrypted, ssh_port, notes)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
name,
|
||||||
|
api_username,
|
||||||
|
encApi,
|
||||||
|
api_port ?? null,
|
||||||
|
ssh_username || null,
|
||||||
|
encSsh,
|
||||||
|
ssh_port ?? null,
|
||||||
|
notes || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return res.status(201).json(toPublic(rows[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/credential-presets/:id — admin only
|
||||||
|
router.put('/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||||
|
|
||||||
|
const existing = await queryOne<CredentialPresetRow>(
|
||||||
|
`SELECT * FROM credential_presets WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Preset not found' });
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
api_username,
|
||||||
|
api_password,
|
||||||
|
api_port,
|
||||||
|
ssh_username,
|
||||||
|
ssh_password,
|
||||||
|
ssh_port,
|
||||||
|
notes,
|
||||||
|
clear_ssh_password,
|
||||||
|
} = req.body as {
|
||||||
|
name?: string;
|
||||||
|
api_username?: string;
|
||||||
|
api_password?: string;
|
||||||
|
api_port?: number | null;
|
||||||
|
ssh_username?: string | null;
|
||||||
|
ssh_password?: string | null;
|
||||||
|
ssh_port?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
// Explicit flag to remove a previously-saved SSH password
|
||||||
|
clear_ssh_password?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof name === 'string' && name && name !== existing.name) {
|
||||||
|
const clash = await queryOne<{ id: number }>(
|
||||||
|
`SELECT id FROM credential_presets WHERE name = $1 AND id <> $2`,
|
||||||
|
[name, id]
|
||||||
|
);
|
||||||
|
if (clash) return res.status(409).json({ error: 'A preset with this name already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newApiPass = api_password ? encrypt(api_password) : existing.api_password_encrypted;
|
||||||
|
let newSshPass: string | null = existing.ssh_password_encrypted;
|
||||||
|
if (clear_ssh_password) newSshPass = null;
|
||||||
|
if (ssh_password) newSshPass = encrypt(ssh_password);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE credential_presets SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
api_username = COALESCE($2, api_username),
|
||||||
|
api_password_encrypted = $3,
|
||||||
|
api_port = $4,
|
||||||
|
ssh_username = $5,
|
||||||
|
ssh_password_encrypted = $6,
|
||||||
|
ssh_port = $7,
|
||||||
|
notes = $8,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $9`,
|
||||||
|
[
|
||||||
|
name ?? null,
|
||||||
|
api_username ?? null,
|
||||||
|
newApiPass,
|
||||||
|
api_port === undefined ? existing.api_port : api_port,
|
||||||
|
ssh_username === undefined ? existing.ssh_username : (ssh_username || null),
|
||||||
|
newSshPass,
|
||||||
|
ssh_port === undefined ? existing.ssh_port : ssh_port,
|
||||||
|
notes === undefined ? existing.notes : (notes || null),
|
||||||
|
id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await queryOne<CredentialPresetRow>(
|
||||||
|
`SELECT * FROM credential_presets WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return res.json(toPublic(updated!));
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/credential-presets/:id — admin only
|
||||||
|
router.delete('/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||||
|
const result = await query(
|
||||||
|
`DELETE FROM credential_presets WHERE id = $1 RETURNING id`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!result.length) return res.status(404).json({ error: 'Preset not found' });
|
||||||
|
return res.json({ message: 'Preset deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+256
-24
@@ -5,6 +5,35 @@ import { encrypt, decrypt } from '../utils/crypto';
|
|||||||
import { RouterOSClient } from '../services/mikrotik/RouterOSClient';
|
import { RouterOSClient } from '../services/mikrotik/RouterOSClient';
|
||||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||||
import { PollerService } from '../services/PollerService';
|
import { PollerService } from '../services/PollerService';
|
||||||
|
import type { CredentialPresetRow } from './credentialPresets';
|
||||||
|
|
||||||
|
// Resolve a credential preset id into decrypted credentials. Returns null if
|
||||||
|
// no id was provided; throws a readable error if the id is invalid.
|
||||||
|
async function loadCredentialPreset(
|
||||||
|
id: number | null | undefined
|
||||||
|
): Promise<{
|
||||||
|
api_username: string;
|
||||||
|
api_password: string;
|
||||||
|
api_port: number | null;
|
||||||
|
ssh_username: string | null;
|
||||||
|
ssh_password: string | null;
|
||||||
|
ssh_port: number | null;
|
||||||
|
} | null> {
|
||||||
|
if (id === null || id === undefined) return null;
|
||||||
|
const preset = await queryOne<CredentialPresetRow>(
|
||||||
|
`SELECT * FROM credential_presets WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!preset) throw new Error(`Credential preset ${id} not found`);
|
||||||
|
return {
|
||||||
|
api_username: preset.api_username,
|
||||||
|
api_password: decrypt(preset.api_password_encrypted),
|
||||||
|
api_port: preset.api_port,
|
||||||
|
ssh_username: preset.ssh_username,
|
||||||
|
ssh_password: preset.ssh_password_encrypted ? decrypt(preset.ssh_password_encrypted) : null,
|
||||||
|
ssh_port: preset.ssh_port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
@@ -71,6 +100,32 @@ router.get('/discovered', async (_req: Request, res: Response) => {
|
|||||||
ORDER BY tl.discovered_at DESC
|
ORDER BY tl.discovered_at DESC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Build lookup tables for "is this actually a managed device we already know?"
|
||||||
|
// We match a discovered neighbor to a managed device by any of:
|
||||||
|
// - its MAC matching any interface MAC of a managed device,
|
||||||
|
// - its IP matching the managed device's primary ip_address,
|
||||||
|
// - its identity matching the managed device's name.
|
||||||
|
const [managedByMac, managedByIp, managedByName] = await Promise.all([
|
||||||
|
query<{ device_id: number; device_name: string; mac: string }>(
|
||||||
|
`SELECT i.device_id AS device_id, d.name AS device_name, LOWER(i.mac_address) AS mac
|
||||||
|
FROM interfaces i
|
||||||
|
JOIN devices d ON d.id = i.device_id
|
||||||
|
WHERE i.mac_address IS NOT NULL AND i.mac_address <> ''`
|
||||||
|
),
|
||||||
|
query<{ id: number; name: string; ip_address: string }>(
|
||||||
|
`SELECT id, name, ip_address FROM devices WHERE ip_address IS NOT NULL AND ip_address <> ''`
|
||||||
|
),
|
||||||
|
query<{ id: number; name: string }>(
|
||||||
|
`SELECT id, name FROM devices WHERE name IS NOT NULL AND name <> ''`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const macToDevice = new Map<string, { id: number; name: string }>();
|
||||||
|
for (const r of managedByMac) macToDevice.set(r.mac, { id: r.device_id, name: r.device_name });
|
||||||
|
const ipToDevice = new Map<string, { id: number; name: string }>();
|
||||||
|
for (const r of managedByIp) ipToDevice.set(r.ip_address, { id: r.id, name: r.name });
|
||||||
|
const nameToDevice = new Map<string, { id: number; name: string }>();
|
||||||
|
for (const r of managedByName) nameToDevice.set(r.name.toLowerCase(), { id: r.id, name: r.name });
|
||||||
|
|
||||||
// Deduplicate by MAC (most reliable), fallback to IP, then identity
|
// Deduplicate by MAC (most reliable), fallback to IP, then identity
|
||||||
const seen = new Map<string, typeof rows[0] & { seen_by_list: string[] }>();
|
const seen = new Map<string, typeof rows[0] & { seen_by_list: string[] }>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -86,36 +141,161 @@ router.get('/discovered', async (_req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = Array.from(seen.values()).map((r) => ({
|
const results = Array.from(seen.values()).map((r) => {
|
||||||
identity: r.neighbor_identity || '',
|
const mac = (r.neighbor_mac || '').toLowerCase();
|
||||||
address: r.neighbor_address || '',
|
const match =
|
||||||
mac_address: r.neighbor_mac || '',
|
(mac && macToDevice.get(mac)) ||
|
||||||
platform: r.neighbor_platform || '',
|
(r.neighbor_address && ipToDevice.get(r.neighbor_address)) ||
|
||||||
discovered_at: r.discovered_at,
|
(r.neighbor_identity && nameToDevice.get(r.neighbor_identity.toLowerCase())) ||
|
||||||
seen_by: r.seen_by_list.join(', '),
|
null;
|
||||||
}));
|
return {
|
||||||
|
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(', '),
|
||||||
|
duplicate_of_device_id: match ? match.id : null,
|
||||||
|
duplicate_of_device_name: match ? match.name : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
res.json(results);
|
res.json(results);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/devices
|
// POST /api/devices
|
||||||
router.post('/', requireWrite, async (req: Request, res: Response) => {
|
router.post('/', requireWrite, async (req: Request, res: Response) => {
|
||||||
const { name, ip_address, api_port = 8728, api_username, api_password,
|
const {
|
||||||
ssh_port = 22, ssh_username, ssh_password, device_type = 'router', notes } = req.body;
|
name, ip_address, device_type = 'router', notes,
|
||||||
|
credential_preset_id,
|
||||||
|
} = req.body as { name?: string; ip_address?: string; device_type?: string; notes?: string; credential_preset_id?: number | null };
|
||||||
|
|
||||||
|
// If a preset is supplied, its credentials win over anything else in the
|
||||||
|
// body — the client just needs to say "use preset 3". Anything the body
|
||||||
|
// also sends (like ssh_username) is ignored in favor of preset values, so
|
||||||
|
// "deploying" a preset always produces a consistent device record.
|
||||||
|
let preset: Awaited<ReturnType<typeof loadCredentialPreset>> = null;
|
||||||
|
try {
|
||||||
|
preset = await loadCredentialPreset(credential_preset_id ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const api_username: string | undefined = preset?.api_username ?? req.body.api_username;
|
||||||
|
const api_password: string | undefined = preset?.api_password ?? req.body.api_password;
|
||||||
|
const parsePort = (v: unknown, fallback: number): number => {
|
||||||
|
if (v == null || v === '') return fallback;
|
||||||
|
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
};
|
||||||
|
const api_port: number = preset?.api_port ?? parsePort(req.body.api_port, 8728);
|
||||||
|
const ssh_username: string | null = preset ? preset.ssh_username : (req.body.ssh_username ?? null);
|
||||||
|
const ssh_password: string | null = preset ? preset.ssh_password : (req.body.ssh_password ?? null);
|
||||||
|
const ssh_port: number = preset?.ssh_port ?? parsePort(req.body.ssh_port, 22);
|
||||||
|
const combineWithDeviceId =
|
||||||
|
typeof req.body.combine_with_device_id === 'number' ? req.body.combine_with_device_id : null;
|
||||||
|
const forceReplaceBySerial = req.body.force_replace_existing_by_serial === true;
|
||||||
|
|
||||||
if (!name || !ip_address || !api_username || !api_password) {
|
if (!name || !ip_address || !api_username || !api_password) {
|
||||||
return res.status(400).json({ error: 'name, ip_address, api_username, api_password are required' });
|
return res.status(400).json({ error: 'name, ip_address, api_username, api_password are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test connection before saving
|
// Test connection and detect hardware serial before saving
|
||||||
const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000);
|
const testClient = new RouterOSClient(ip_address, api_port, api_username, api_password, 10_000);
|
||||||
|
let detectedSerial: string | null = null;
|
||||||
try {
|
try {
|
||||||
await testClient.connect();
|
await testClient.connect();
|
||||||
testClient.disconnect();
|
const rb = await testClient.execute('/system/routerboard/print').catch(() => [] as Record<string, string>[]);
|
||||||
|
detectedSerial = (rb[0]?.['serial-number'] || '').trim() || null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(422).json({
|
return res.status(422).json({
|
||||||
error: `Cannot connect to device: ${(err as Error).message}`,
|
error: `Cannot connect to device: ${(err as Error).message}`,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
testClient.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedSerial) {
|
||||||
|
const existingBySerial = await queryOne<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip_address: string;
|
||||||
|
serial_number: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, name, ip_address, serial_number
|
||||||
|
FROM devices
|
||||||
|
WHERE serial_number = $1`,
|
||||||
|
[detectedSerial]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingBySerial) {
|
||||||
|
const shouldCombine =
|
||||||
|
combineWithDeviceId != null && combineWithDeviceId === existingBySerial.id;
|
||||||
|
if (!shouldCombine && !forceReplaceBySerial) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'duplicate_serial',
|
||||||
|
code: 'duplicate_serial',
|
||||||
|
existing_device: existingBySerial,
|
||||||
|
candidate: {
|
||||||
|
serial_number: detectedSerial,
|
||||||
|
identity: name,
|
||||||
|
ip_address,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedPass = encrypt(api_password);
|
||||||
|
const encryptedSshPass = ssh_password ? encrypt(ssh_password) : null;
|
||||||
|
await query(
|
||||||
|
`UPDATE devices SET
|
||||||
|
name=COALESCE($1,name),
|
||||||
|
ip_address=$2,
|
||||||
|
api_port=$3,
|
||||||
|
api_username=$4,
|
||||||
|
api_password_encrypted=$5,
|
||||||
|
ssh_port=$6,
|
||||||
|
ssh_username=COALESCE($7,ssh_username),
|
||||||
|
ssh_password_encrypted=COALESCE($8,ssh_password_encrypted),
|
||||||
|
device_type=COALESCE($9,device_type),
|
||||||
|
notes=COALESCE($10,notes),
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE id = $11`,
|
||||||
|
[
|
||||||
|
name,
|
||||||
|
ip_address,
|
||||||
|
api_port,
|
||||||
|
api_username,
|
||||||
|
encryptedPass,
|
||||||
|
ssh_port,
|
||||||
|
ssh_username,
|
||||||
|
encryptedSshPass,
|
||||||
|
device_type,
|
||||||
|
notes || null,
|
||||||
|
existingBySerial.id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pollerService) {
|
||||||
|
await pollerService.scheduleDeviceSync(existingBySerial.id, 'full');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedExisting = 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`,
|
||||||
|
[existingBySerial.id]
|
||||||
|
);
|
||||||
|
if (!updatedExisting) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Duplicate merge succeeded but device row could not be reloaded',
|
||||||
|
device_id: existingBySerial.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.status(200).json({
|
||||||
|
...updatedExisting,
|
||||||
|
merged_from_duplicate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedPass = encrypt(api_password);
|
const encryptedPass = encrypt(api_password);
|
||||||
@@ -204,28 +384,80 @@ router.patch('/:id/location', requireWrite, async (req: Request, res: Response)
|
|||||||
|
|
||||||
// PUT /api/devices/:id
|
// PUT /api/devices/:id
|
||||||
router.put('/:id', requireWrite, async (req: Request, res: Response) => {
|
router.put('/:id', requireWrite, async (req: Request, res: Response) => {
|
||||||
const { name, api_port, api_username, api_password, ssh_port, ssh_username,
|
const { name, ip_address, device_type, notes, credential_preset_id } = req.body as {
|
||||||
ssh_password, device_type, notes } = req.body;
|
name?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
device_type?: string;
|
||||||
|
notes?: string;
|
||||||
|
credential_preset_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
const existing = await queryOne<{ id: number; api_password_encrypted: string }>(
|
let preset: Awaited<ReturnType<typeof loadCredentialPreset>> = null;
|
||||||
`SELECT id, api_password_encrypted FROM devices WHERE id = $1`,
|
try {
|
||||||
|
preset = await loadCredentialPreset(credential_preset_id ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a preset is applied, its values take precedence over anything else
|
||||||
|
// in the body so "apply preset" has a single unambiguous meaning.
|
||||||
|
const api_port = preset?.api_port ?? req.body.api_port;
|
||||||
|
const api_username = preset?.api_username ?? req.body.api_username;
|
||||||
|
const api_password = preset?.api_password ?? req.body.api_password;
|
||||||
|
const ssh_port = preset?.ssh_port ?? req.body.ssh_port;
|
||||||
|
const ssh_username = preset ? preset.ssh_username : req.body.ssh_username;
|
||||||
|
const ssh_password = preset ? preset.ssh_password : req.body.ssh_password;
|
||||||
|
|
||||||
|
const existing = 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]
|
[req.params.id]
|
||||||
);
|
);
|
||||||
if (!existing) return res.status(404).json({ error: 'Device not found' });
|
if (!existing) return res.status(404).json({ error: 'Device not found' });
|
||||||
|
|
||||||
|
// If the user is changing the IP (or port / username / password), verify the
|
||||||
|
// RouterOS API is still reachable with the new values before persisting —
|
||||||
|
// otherwise we could silently brick the device record.
|
||||||
|
const ipChanged = typeof ip_address === 'string' && ip_address && ip_address !== existing.ip_address;
|
||||||
|
const portChanged = typeof api_port === 'number' && api_port !== existing.api_port;
|
||||||
|
const userChanged = typeof api_username === 'string' && api_username && api_username !== existing.api_username;
|
||||||
|
const presetReplacesApiCreds = !!preset;
|
||||||
|
if (ipChanged || portChanged || userChanged || api_password || presetReplacesApiCreds) {
|
||||||
|
const testIp = ip_address ?? existing.ip_address;
|
||||||
|
const testPort = (typeof api_port === 'number' ? api_port : undefined) ?? existing.api_port;
|
||||||
|
const testUser = api_username ?? existing.api_username;
|
||||||
|
const testPass = api_password ? api_password : decrypt(existing.api_password_encrypted);
|
||||||
|
const testClient = new RouterOSClient(testIp, testPort, testUser, testPass, 10_000);
|
||||||
|
try {
|
||||||
|
await testClient.connect();
|
||||||
|
testClient.disconnect();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(422).json({
|
||||||
|
error: `Cannot connect to device with new settings: ${(err as Error).message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const encPass = api_password ? encrypt(api_password) : existing.api_password_encrypted;
|
const encPass = api_password ? encrypt(api_password) : existing.api_password_encrypted;
|
||||||
const encSshPass = ssh_password ? encrypt(ssh_password) : null;
|
const encSshPass = ssh_password ? encrypt(ssh_password) : null;
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE devices SET
|
`UPDATE devices SET
|
||||||
name=COALESCE($1,name), api_port=COALESCE($2,api_port),
|
name=COALESCE($1,name), ip_address=COALESCE($2,ip_address),
|
||||||
api_username=COALESCE($3,api_username), api_password_encrypted=$4,
|
api_port=COALESCE($3,api_port),
|
||||||
ssh_port=COALESCE($5,ssh_port), ssh_username=COALESCE($6,ssh_username),
|
api_username=COALESCE($4,api_username), api_password_encrypted=$5,
|
||||||
ssh_password_encrypted=COALESCE($7,ssh_password_encrypted),
|
ssh_port=COALESCE($6,ssh_port), ssh_username=COALESCE($7,ssh_username),
|
||||||
device_type=COALESCE($8,device_type), notes=COALESCE($9,notes),
|
ssh_password_encrypted=COALESCE($8,ssh_password_encrypted),
|
||||||
|
device_type=COALESCE($9,device_type), notes=COALESCE($10,notes),
|
||||||
updated_at=NOW()
|
updated_at=NOW()
|
||||||
WHERE id = $10`,
|
WHERE id = $11`,
|
||||||
[name, api_port, api_username, encPass, ssh_port, ssh_username, encSshPass, device_type, notes, req.params.id]
|
[name, ip_address, api_port, api_username, encPass, ssh_port, ssh_username, encSshPass, device_type, notes, req.params.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = await queryOne(
|
const updated = await queryOne(
|
||||||
@@ -732,7 +964,7 @@ router.post('/:id/check-update', async (req: Request, res: Response) => {
|
|||||||
const statusText = (updateInfo['status'] ?? '').toLowerCase();
|
const statusText = (updateInfo['status'] ?? '').toLowerCase();
|
||||||
const hasUpdate =
|
const hasUpdate =
|
||||||
statusText.includes('available') ||
|
statusText.includes('available') ||
|
||||||
(latestVersion && installedVersion && latestVersion !== installedVersion);
|
Boolean(latestVersion && installedVersion && latestVersion !== installedVersion);
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE devices SET firmware_update_available = $1, latest_ros_version = $2, updated_at = NOW() WHERE id = $3`,
|
`UPDATE devices SET firmware_update_available = $1, latest_ros_version = $2, updated_at = NOW() WHERE id = $3`,
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ router.get('/', async (_req: Request, res: Response) => {
|
|||||||
|
|
||||||
const [devices, allLinks] = await Promise.all([
|
const [devices, allLinks] = await Promise.all([
|
||||||
query(
|
query(
|
||||||
`SELECT id, name, ip_address, model, device_type, status, ros_version
|
`SELECT id, name, ip_address, model, device_type, status, ros_version,
|
||||||
|
ip_addresses_jsonb
|
||||||
FROM devices ORDER BY name ASC`
|
FROM devices ORDER BY name ASC`
|
||||||
),
|
),
|
||||||
query<LinkRow>(
|
query<LinkRow>(
|
||||||
@@ -70,6 +71,51 @@ router.get('/', async (_req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
const links = Array.from(bestLinks.values());
|
const links = Array.from(bestLinks.values());
|
||||||
|
|
||||||
|
// ── Resolve neighbor IP → managed device (any IP on that device, not just mgmt) ──
|
||||||
|
// CDP/MNDP often report only an IP. We cache /ip/address per device during sync;
|
||||||
|
// if a neighbor address matches any managed device's known address, treat the
|
||||||
|
// link as device-to-device instead of creating an external node.
|
||||||
|
const stripCidr = (addr: string): string => {
|
||||||
|
const a = addr.trim();
|
||||||
|
const slash = a.indexOf('/');
|
||||||
|
return slash === -1 ? a : a.slice(0, slash);
|
||||||
|
};
|
||||||
|
const normIp = (addr: string): string => stripCidr(addr).toLowerCase();
|
||||||
|
|
||||||
|
const ipToDevice = new Map<string, { id: number; name: string }>();
|
||||||
|
for (const d of devices as {
|
||||||
|
id: number; name: string; ip_address: string;
|
||||||
|
ip_addresses_jsonb?: unknown;
|
||||||
|
}[]) {
|
||||||
|
if (d.ip_address) {
|
||||||
|
const k = normIp(d.ip_address);
|
||||||
|
if (k && !ipToDevice.has(k)) ipToDevice.set(k, { id: d.id, name: d.name });
|
||||||
|
}
|
||||||
|
const list = d.ip_addresses_jsonb;
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
for (const entry of list) {
|
||||||
|
const raw = typeof entry === 'object' && entry && 'address' in entry
|
||||||
|
? String((entry as { address?: string }).address || '')
|
||||||
|
: '';
|
||||||
|
const k = normIp(raw);
|
||||||
|
if (k && !ipToDevice.has(k)) ipToDevice.set(k, { id: d.id, name: d.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
if (link.to_device_id) continue;
|
||||||
|
const na = link.neighbor_address?.trim();
|
||||||
|
if (!na || na.includes('%')) continue; // skip zone-id style
|
||||||
|
const key = normIp(na);
|
||||||
|
if (!key) continue;
|
||||||
|
const hit = ipToDevice.get(key);
|
||||||
|
if (hit && hit.id !== link.from_device_id) {
|
||||||
|
link.to_device_id = hit.id;
|
||||||
|
link.to_device_name = hit.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build synthetic external nodes from unresolved neighbor entries
|
// Build synthetic external nodes from unresolved neighbor entries
|
||||||
const externalMap = new Map<string, {
|
const externalMap = new Map<string, {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -656,9 +656,10 @@ export class PollerService {
|
|||||||
const installedVersion = (updateInfo['installed-version'] ?? '').trim();
|
const installedVersion = (updateInfo['installed-version'] ?? '').trim();
|
||||||
const statusText = (updateInfo['status'] ?? '').toLowerCase();
|
const statusText = (updateInfo['status'] ?? '').toLowerCase();
|
||||||
|
|
||||||
const hasUpdate =
|
const hasUpdate = Boolean(
|
||||||
statusText.includes('available') ||
|
statusText.includes('available') ||
|
||||||
(latestVersion && installedVersion && latestVersion !== installedVersion);
|
(latestVersion && installedVersion && latestVersion !== installedVersion)
|
||||||
|
);
|
||||||
|
|
||||||
// Read current flag before updating so we can detect first-discovery
|
// Read current flag before updating so we can detect first-discovery
|
||||||
const current = await query<{ firmware_update_available: boolean }>(
|
const current = await query<{ firmware_update_available: boolean }>(
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import { decrypt } from '../../utils/crypto';
|
|||||||
import { lookupVendor } from '../../utils/oui';
|
import { lookupVendor } from '../../utils/oui';
|
||||||
import { buildServerArpMap } from '../../utils/serverArp';
|
import { buildServerArpMap } from '../../utils/serverArp';
|
||||||
|
|
||||||
|
/** DB column limits for topology_links (see migrate.ts); reject oversize rows instead of silent truncation. */
|
||||||
|
const TOPOLOGY_LINK_LIMITS = {
|
||||||
|
from_interface: 512,
|
||||||
|
to_interface: 512,
|
||||||
|
neighbor_address: 45,
|
||||||
|
neighbor_identity: 255,
|
||||||
|
neighbor_platform: 255,
|
||||||
|
neighbor_mac: 17,
|
||||||
|
neighbor_caps: 255,
|
||||||
|
discovered_by: 512,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function topologyNeighborOversized(
|
||||||
|
field: keyof typeof TOPOLOGY_LINK_LIMITS,
|
||||||
|
value: string | null | undefined
|
||||||
|
): { limit: number; len: number } | null {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
const limit = TOPOLOGY_LINK_LIMITS[field];
|
||||||
|
return value.length > limit ? { limit, len: value.length } : null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeviceRow {
|
export interface DeviceRow {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -57,6 +78,7 @@ export class DeviceCollector {
|
|||||||
|
|
||||||
async collectSlow(): Promise<void> {
|
async collectSlow(): Promise<void> {
|
||||||
await this.collectInterfaces();
|
await this.collectInterfaces();
|
||||||
|
await this.collectIpAddressesCache();
|
||||||
await this.collectVlans();
|
await this.collectVlans();
|
||||||
await this.collectSystemInfo();
|
await this.collectSystemInfo();
|
||||||
await this.collectStp();
|
await this.collectStp();
|
||||||
@@ -77,6 +99,7 @@ export class DeviceCollector {
|
|||||||
async collectAll(): Promise<void> {
|
async collectAll(): Promise<void> {
|
||||||
await this.collectSystemInfo();
|
await this.collectSystemInfo();
|
||||||
await this.collectInterfaces();
|
await this.collectInterfaces();
|
||||||
|
await this.collectIpAddressesCache();
|
||||||
await this.collectVlans();
|
await this.collectVlans();
|
||||||
await this.collectInterfaceTraffic();
|
await this.collectInterfaceTraffic();
|
||||||
await this.collectResourceUsage();
|
await this.collectResourceUsage();
|
||||||
@@ -269,6 +292,29 @@ export class DeviceCollector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Store /ip/address rows (sanitized) for topology matching of neighbor IPs. */
|
||||||
|
async collectIpAddressesCache(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rows = await this.client
|
||||||
|
.execute('/ip/address/print', { detail: '' })
|
||||||
|
.catch(() => [] as Record<string, string>[]);
|
||||||
|
const minimalist = rows
|
||||||
|
.filter((r) => r['disabled'] !== 'true' && r['invalid'] !== 'true')
|
||||||
|
.map((r) => ({
|
||||||
|
address: (r['address'] || '').trim(),
|
||||||
|
interface: (r['interface'] || '').trim(),
|
||||||
|
dynamic: r['dynamic'] === 'true',
|
||||||
|
}))
|
||||||
|
.filter((x) => x.address.length > 0);
|
||||||
|
await query(
|
||||||
|
`UPDATE devices SET ip_addresses_jsonb = $1::jsonb, updated_at = NOW() WHERE id = $2`,
|
||||||
|
[JSON.stringify(minimalist), this.device.id]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${this.device.name}] Failed to cache IP addresses:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── VLANs → Postgres ────────────────────────────────────────────────────
|
// ─── VLANs → Postgres ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async collectVlans(): Promise<void> {
|
async collectVlans(): Promise<void> {
|
||||||
@@ -769,10 +815,11 @@ export class DeviceCollector {
|
|||||||
// first entry is always the physical port; the rest are the bridge/bond parents.
|
// 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.
|
// Store only the physical port so topology edges show the actual cable endpoint.
|
||||||
const rawInterface = nb['interface'] || '';
|
const rawInterface = nb['interface'] || '';
|
||||||
const fromInterface = rawInterface.split(',')[0].trim() || rawInterface;
|
const fromInterface = (rawInterface.split(',')[0].trim() || rawInterface);
|
||||||
|
|
||||||
// interface-name is the neighbor's own outgoing interface (format: bridge/port or just port)
|
// interface-name is the neighbor's own outgoing interface (format: bridge/port or just port)
|
||||||
const toInterface = (nb['interface-name'] || '').trim() || null;
|
const toInterfaceRaw = (nb['interface-name'] || '').trim() || null;
|
||||||
|
const toInterface = toInterfaceRaw || null;
|
||||||
|
|
||||||
// discovered-by lists protocols that found this neighbor: lldp, cdp, mndp, etc.
|
// 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
|
// Rank them by reliability: lldp (point-to-point) > cdp (also flooded in ROS but
|
||||||
@@ -783,6 +830,9 @@ export class DeviceCollector {
|
|||||||
else if (discoveredByRaw.includes('cdp')) discoveredBy = 'cdp';
|
else if (discoveredByRaw.includes('cdp')) discoveredBy = 'cdp';
|
||||||
else if (discoveredByRaw.includes('mndp')) discoveredBy = 'mndp';
|
else if (discoveredByRaw.includes('mndp')) discoveredBy = 'mndp';
|
||||||
else discoveredBy = discoveredByRaw || 'mndp';
|
else discoveredBy = discoveredByRaw || 'mndp';
|
||||||
|
// Store the full RouterOS list in discovered_by (column is VARCHAR(512)); the raw
|
||||||
|
// string can be a long comma-separated list and used to exceed VARCHAR(50).
|
||||||
|
const discoveredByStored = discoveredByRaw || null;
|
||||||
|
|
||||||
// RouterOS v7+ may put an IPv6 link-local in 'address'. Try all known
|
// 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
|
// IPv4 field names, split on whitespace/commas in case multiple are packed
|
||||||
@@ -800,20 +850,52 @@ export class DeviceCollector {
|
|||||||
const neighborAddress = ipv4FromNeighbor
|
const neighborAddress = ipv4FromNeighbor
|
||||||
|| (mac ? macToIpv4[mac] ?? null : null)
|
|| (mac ? macToIpv4[mac] ?? null : null)
|
||||||
|| (mac ? serverArpMap[mac] ?? null : null);
|
|| (mac ? serverArpMap[mac] ?? null : null);
|
||||||
const neighborIdentity = nb['identity'] || '';
|
const neighborIdentity = (nb['identity'] || '');
|
||||||
const neighborPlatform = nb['platform'] || '';
|
const neighborPlatform = (nb['platform'] || '');
|
||||||
const neighborMac = nb['mac-address'] || null;
|
const neighborMac = nb['mac-address'] ? String(nb['mac-address']) : null;
|
||||||
const neighborCaps = nb['system-caps-enabled'] || nb['system-caps'] || '';
|
const neighborCaps = (nb['system-caps-enabled'] || nb['system-caps'] || '');
|
||||||
|
|
||||||
if (!fromInterface) continue;
|
if (!fromInterface) continue;
|
||||||
|
|
||||||
|
const oversize: { field: keyof typeof TOPOLOGY_LINK_LIMITS; limit: number; len: number }[] = [];
|
||||||
|
const check = (field: keyof typeof TOPOLOGY_LINK_LIMITS, val: string | null | undefined) => {
|
||||||
|
const o = topologyNeighborOversized(field, val);
|
||||||
|
if (o) oversize.push({ field, ...o });
|
||||||
|
};
|
||||||
|
check('from_interface', fromInterface);
|
||||||
|
check('to_interface', toInterface);
|
||||||
|
check('neighbor_address', neighborAddress ? String(neighborAddress) : null);
|
||||||
|
check('neighbor_identity', neighborIdentity || null);
|
||||||
|
check('neighbor_platform', neighborPlatform || null);
|
||||||
|
check('neighbor_mac', neighborMac);
|
||||||
|
check('neighbor_caps', neighborCaps || null);
|
||||||
|
check('discovered_by', discoveredByStored);
|
||||||
|
|
||||||
|
if (oversize.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[${this.device.name}] Skipping topology neighbor row: value(s) exceed DB column limits ` +
|
||||||
|
`(no silent truncation). Details: ${JSON.stringify(oversize)}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO topology_links
|
`INSERT INTO topology_links
|
||||||
(from_device_id, from_interface, to_interface, neighbor_address, neighbor_identity,
|
(from_device_id, from_interface, to_interface, neighbor_address, neighbor_identity,
|
||||||
neighbor_platform, neighbor_mac, neighbor_caps, link_type, discovered_by, discovered_at)
|
neighbor_platform, neighbor_mac, neighbor_caps, link_type, discovered_by, discovered_at)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())`,
|
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]
|
this.device.id,
|
||||||
|
fromInterface,
|
||||||
|
toInterface,
|
||||||
|
neighborAddress ? String(neighborAddress) : null,
|
||||||
|
neighborIdentity,
|
||||||
|
neighborPlatform,
|
||||||
|
neighborMac,
|
||||||
|
neighborCaps || null,
|
||||||
|
discoveredBy,
|
||||||
|
discoveredByStored,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* AES-256-GCM helpers for encrypting device credentials at rest in PostgreSQL.
|
||||||
|
*
|
||||||
|
* Key material: set `ENCRYPTION_KEY` in the environment (see README). If unset,
|
||||||
|
* a development-only default is used — production deployments must set a
|
||||||
|
* strong secret. Key rotation / recovery is documented under README
|
||||||
|
* "Credential encryption (ENCRYPTION_KEY)".
|
||||||
|
*/
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
image: ghcr.io/2gt-media-group-llc/mikrotik-manager-backend:latest
|
image: ghcr.io/2gt-media-group-llc/mikrotik-manager-backend:latest
|
||||||
environment:
|
environment:
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://mikrotik:${DB_PASSWORD:-mikrotik_secure_pw}@postgres:5432/mikrotik_manager
|
DATABASE_URL: postgresql://mikrotik:${DB_PASSWORD:-mikrotik_secure_pw}@postgres:5432/mikrotik_manager
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
DATABASE_URL: postgresql://mikrotik:${DB_PASSWORD:-mikrotik_secure_pw}@postgres:5432/mikrotik_manager
|
DATABASE_URL: postgresql://mikrotik:${DB_PASSWORD:-mikrotik_secure_pw}@postgres:5432/mikrotik_manager
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
INFLUXDB_URL: http://influxdb:8086
|
INFLUXDB_URL: http://influxdb:8086
|
||||||
@@ -57,6 +58,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
environment:
|
environment:
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
POSTGRES_DB: mikrotik_manager
|
POSTGRES_DB: mikrotik_manager
|
||||||
POSTGRES_USER: mikrotik
|
POSTGRES_USER: mikrotik
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-mikrotik_secure_pw}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-mikrotik_secure_pw}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, FormEvent } from 'react';
|
||||||
import { X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { devicesApi } from '../../services/api';
|
import { X, CheckCircle, AlertCircle, Loader2, KeyRound } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
devicesApi,
|
||||||
|
credentialPresetsApi,
|
||||||
|
type DuplicateSerialError,
|
||||||
|
} from '../../services/api';
|
||||||
|
import ConfirmDuplicateModal from './ConfirmDuplicateModal';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -21,36 +27,120 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
|||||||
device_type: 'router',
|
device_type: 'router',
|
||||||
notes: '',
|
notes: '',
|
||||||
});
|
});
|
||||||
|
const [presetId, setPresetId] = useState<number | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [duplicateSerial, setDuplicateSerial] = useState<DuplicateSerialError | null>(null);
|
||||||
|
|
||||||
|
const { data: presets = [] } = useQuery({
|
||||||
|
queryKey: ['credential-presets'],
|
||||||
|
queryFn: () => credentialPresetsApi.list().then((r) => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPreset = presetId != null ? presets.find((p) => p.id === presetId) ?? null : null;
|
||||||
|
|
||||||
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const createPayload = (
|
||||||
|
opts: { combineWithDeviceId?: number; forceReplace?: boolean } = {}
|
||||||
|
) => {
|
||||||
|
const extra = {
|
||||||
|
combine_with_device_id: opts.combineWithDeviceId,
|
||||||
|
force_replace_existing_by_serial: opts.forceReplace,
|
||||||
|
};
|
||||||
|
if (selectedPreset) {
|
||||||
|
return {
|
||||||
|
name: form.name,
|
||||||
|
ip_address: form.ip_address,
|
||||||
|
device_type: form.device_type as import('../../types').DeviceType,
|
||||||
|
notes: form.notes,
|
||||||
|
credential_preset_id: selectedPreset.id,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: form.name,
|
||||||
|
ip_address: form.ip_address,
|
||||||
|
api_port: parseInt(form.api_port, 10),
|
||||||
|
api_username: form.api_username,
|
||||||
|
api_password: form.api_password,
|
||||||
|
ssh_port: parseInt(form.ssh_port, 10),
|
||||||
|
ssh_username: form.ssh_username || undefined,
|
||||||
|
ssh_password: form.ssh_password || undefined,
|
||||||
|
device_type: form.device_type as import('../../types').DeviceType,
|
||||||
|
notes: form.notes,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form.name || !form.ip_address || !form.api_username || !form.api_password) {
|
if (!form.name || !form.ip_address) {
|
||||||
setError('Name, IP address, username, and password are required');
|
setError('Name and IP address are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedPreset && (!form.api_username || !form.api_password)) {
|
||||||
|
setError('Either pick a credential preset or enter username and password');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
setDuplicateSerial(null);
|
||||||
try {
|
try {
|
||||||
await devicesApi.create({
|
await devicesApi.create(createPayload());
|
||||||
...form,
|
|
||||||
api_port: parseInt(form.api_port),
|
|
||||||
ssh_port: parseInt(form.ssh_port),
|
|
||||||
device_type: form.device_type as import('../../types').DeviceType,
|
|
||||||
});
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||||
|
const data = (err as { response?: { data?: unknown } })?.response?.data;
|
||||||
|
if (
|
||||||
|
status === 409 &&
|
||||||
|
data &&
|
||||||
|
typeof data === 'object' &&
|
||||||
|
(data as { code?: string }).code === 'duplicate_serial'
|
||||||
|
) {
|
||||||
|
setDuplicateSerial(data as DuplicateSerialError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = (data as { error?: string } | undefined)?.error;
|
||||||
setError(msg || 'Failed to add device');
|
setError(msg || 'Failed to add device');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCombineDuplicate = async () => {
|
||||||
|
if (!duplicateSerial?.existing_device?.id) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await devicesApi.create(createPayload({ combineWithDeviceId: duplicateSerial.existing_device.id }));
|
||||||
|
onSuccess();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
|
setError(msg || 'Failed to combine duplicate by serial');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setDuplicateSerial(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReplaceDuplicate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await devicesApi.create(createPayload({ forceReplace: true }));
|
||||||
|
onSuccess();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
|
setError(msg || 'Failed to replace duplicate serial details');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setDuplicateSerial(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
@@ -100,82 +190,118 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API credentials */}
|
{/* Credentials: preset or manual */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||||
RouterOS API Credentials
|
<KeyRound className="w-3.5 h-3.5" />
|
||||||
|
Credentials
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="label">Username *</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={form.api_username}
|
|
||||||
onChange={(e) => set('api_username', e.target.value)}
|
|
||||||
placeholder="admin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">API Port</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="number"
|
|
||||||
value={form.api_port}
|
|
||||||
onChange={(e) => set('api_port', e.target.value)}
|
|
||||||
placeholder="8728"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="label">Password *</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="password"
|
|
||||||
value={form.api_password}
|
|
||||||
onChange={(e) => set('api_password', e.target.value)}
|
|
||||||
placeholder="RouterOS API password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SSH credentials (optional) */}
|
{presets.length > 0 && (
|
||||||
<details className="group">
|
<div className="mb-3">
|
||||||
<summary className="cursor-pointer text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider select-none">
|
<label className="label">Use a saved preset</label>
|
||||||
SSH Credentials (optional, for backup/restore)
|
<select
|
||||||
</summary>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="label">SSH Username</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
className="input"
|
||||||
value={form.ssh_username}
|
value={presetId ?? ''}
|
||||||
onChange={(e) => set('ssh_username', e.target.value)}
|
onChange={(e) => setPresetId(e.target.value ? parseInt(e.target.value, 10) : null)}
|
||||||
placeholder="admin"
|
>
|
||||||
/>
|
<option value="">— Enter credentials manually —</option>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} ({p.api_username}
|
||||||
|
{p.ssh_username ? ` · SSH ${p.ssh_username}` : ''}
|
||||||
|
)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedPreset && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-slate-400 mt-2">
|
||||||
|
Using preset <strong>{selectedPreset.name}</strong>. The server will pull the
|
||||||
|
API username/password
|
||||||
|
{selectedPreset.has_ssh_password && selectedPreset.ssh_username
|
||||||
|
? ' and SSH credentials'
|
||||||
|
: ''}
|
||||||
|
{' '}from this preset.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<label className="label">SSH Port</label>
|
|
||||||
<input
|
{!selectedPreset && (
|
||||||
className="input"
|
<>
|
||||||
type="number"
|
<div className="grid grid-cols-2 gap-3">
|
||||||
value={form.ssh_port}
|
<div>
|
||||||
onChange={(e) => set('ssh_port', e.target.value)}
|
<label className="label">RouterOS Username *</label>
|
||||||
placeholder="22"
|
<input
|
||||||
/>
|
className="input"
|
||||||
</div>
|
value={form.api_username}
|
||||||
<div className="col-span-2">
|
onChange={(e) => set('api_username', e.target.value)}
|
||||||
<label className="label">SSH Password</label>
|
placeholder="admin"
|
||||||
<input
|
/>
|
||||||
className="input"
|
</div>
|
||||||
type="password"
|
<div>
|
||||||
value={form.ssh_password}
|
<label className="label">API Port</label>
|
||||||
onChange={(e) => set('ssh_password', e.target.value)}
|
<input
|
||||||
autoComplete="new-password"
|
className="input"
|
||||||
/>
|
type="number"
|
||||||
</div>
|
value={form.api_port}
|
||||||
</div>
|
onChange={(e) => set('api_port', e.target.value)}
|
||||||
</details>
|
placeholder="8728"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">RouterOS Password *</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={form.api_password}
|
||||||
|
onChange={(e) => set('api_password', e.target.value)}
|
||||||
|
placeholder="RouterOS API password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSH credentials (optional) */}
|
||||||
|
<details className="group mt-3">
|
||||||
|
<summary className="cursor-pointer text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider select-none">
|
||||||
|
SSH Credentials (optional, for backup/restore)
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">SSH Username</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.ssh_username}
|
||||||
|
onChange={(e) => set('ssh_username', e.target.value)}
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">SSH Port</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
value={form.ssh_port}
|
||||||
|
onChange={(e) => set('ssh_port', e.target.value)}
|
||||||
|
placeholder="22"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">SSH Password</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={form.ssh_password}
|
||||||
|
onChange={(e) => set('ssh_password', e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Notes (optional)</label>
|
<label className="label">Notes (optional)</label>
|
||||||
@@ -219,6 +345,19 @@ export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{duplicateSerial && (
|
||||||
|
<ConfirmDuplicateModal
|
||||||
|
duplicate={duplicateSerial}
|
||||||
|
pendingName={form.name}
|
||||||
|
pendingIp={form.ip_address}
|
||||||
|
onCancel={() => {
|
||||||
|
setDuplicateSerial(null);
|
||||||
|
}}
|
||||||
|
onCombine={handleCombineDuplicate}
|
||||||
|
onReplace={handleReplaceDuplicate}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AlertTriangle, GitMerge, RefreshCcw, X } from 'lucide-react';
|
||||||
|
import type { DuplicateSerialError } from '../../services/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
duplicate: DuplicateSerialError;
|
||||||
|
pendingName: string;
|
||||||
|
pendingIp: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onCombine: () => void;
|
||||||
|
onReplace: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDuplicateModal({
|
||||||
|
duplicate,
|
||||||
|
pendingName,
|
||||||
|
pendingIp,
|
||||||
|
onCancel,
|
||||||
|
onCombine,
|
||||||
|
onReplace,
|
||||||
|
loading,
|
||||||
|
}: Props) {
|
||||||
|
const [confirmCombineStep, setConfirmCombineStep] = useState(false);
|
||||||
|
const existingSerial = (duplicate.existing_device.serial_number || '').trim().toUpperCase();
|
||||||
|
const candidateSerial = (duplicate.candidate.serial_number || '').trim().toUpperCase();
|
||||||
|
const serialsMatch = Boolean(existingSerial && candidateSerial && existingSerial === candidateSerial);
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="card w-full max-w-xl mx-4">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||||
|
Duplicate serial detected
|
||||||
|
</h3>
|
||||||
|
<button onClick={onCancel} disabled={loading} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-slate-300">
|
||||||
|
Serial <span className="font-mono">{duplicate.candidate.serial_number}</span> already exists on a managed device.
|
||||||
|
</p>
|
||||||
|
{confirmCombineStep && (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3">
|
||||||
|
<p className="text-sm font-medium text-amber-800 dark:text-amber-300 mb-1">
|
||||||
|
Confirm combine
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300/90">
|
||||||
|
Existing serial: <span className="font-mono">{duplicate.existing_device.serial_number}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300/90">
|
||||||
|
New device serial: <span className="font-mono">{duplicate.candidate.serial_number}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300/90 mt-2">
|
||||||
|
Only continue if you are certain these represent the same physical device.
|
||||||
|
</p>
|
||||||
|
{!serialsMatch && (
|
||||||
|
<p className="text-xs font-semibold text-red-700 dark:text-red-300 mt-2">
|
||||||
|
Combine is disabled because the serial numbers do not match.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400">Existing managed device</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{duplicate.existing_device.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-slate-400">{duplicate.existing_device.ip_address}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-slate-400 font-mono mt-1">
|
||||||
|
Serial: {duplicate.existing_device.serial_number}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400">New device request</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{pendingName}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-slate-400">{pendingIp}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-slate-400 font-mono mt-1">
|
||||||
|
Serial: {duplicate.candidate.serial_number}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||||
|
<button type="button" onClick={onCancel} disabled={loading} className="btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{confirmCombineStep ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCombine}
|
||||||
|
disabled={loading || !serialsMatch}
|
||||||
|
className="btn-secondary inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<GitMerge className="w-4 h-4" />
|
||||||
|
Confirm combine now
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => setConfirmCombineStep(true)} disabled={loading} className="btn-secondary inline-flex items-center gap-2">
|
||||||
|
<GitMerge className="w-4 h-4" />
|
||||||
|
Continue to combine check
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onReplace} disabled={loading} className="btn-primary inline-flex items-center gap-2">
|
||||||
|
<RefreshCcw className="w-4 h-4" />
|
||||||
|
Replace existing details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { useEffect, useMemo, useState, FormEvent } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { X, CheckCircle, AlertCircle, Loader2, Network } from 'lucide-react';
|
||||||
|
import { devicesApi } from '../../services/api';
|
||||||
|
import type { Device, DeviceType, IpAddress } from '../../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
device: Device;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the CIDR mask from "192.168.88.1/24" → "192.168.88.1" so the value
|
||||||
|
// matches the format stored in `devices.ip_address`.
|
||||||
|
function addrWithoutMask(addr: string): string {
|
||||||
|
if (!addr) return '';
|
||||||
|
const slash = addr.indexOf('/');
|
||||||
|
return slash === -1 ? addr : addr.slice(0, slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide link-local / localhost / invalid rows from the IP picker.
|
||||||
|
function isSelectableIp(row: IpAddress): boolean {
|
||||||
|
const raw = addrWithoutMask(row.address || '');
|
||||||
|
if (!raw) return false;
|
||||||
|
if (row.disabled === 'true') return false;
|
||||||
|
if (row.invalid === 'true') return false;
|
||||||
|
if (raw === '127.0.0.1' || raw === '::1') return false;
|
||||||
|
if (raw.toLowerCase().startsWith('fe80:')) return false;
|
||||||
|
if (raw.startsWith('169.254.')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditDeviceModal({ device, onClose, onSuccess }: Props) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: device.name,
|
||||||
|
ip_address: device.ip_address,
|
||||||
|
api_port: String(device.api_port ?? 8728),
|
||||||
|
api_username: device.api_username ?? 'admin',
|
||||||
|
api_password: '',
|
||||||
|
ssh_port: String(device.ssh_port ?? 22),
|
||||||
|
ssh_username: device.ssh_username ?? '',
|
||||||
|
ssh_password: '',
|
||||||
|
device_type: device.device_type,
|
||||||
|
notes: device.notes ?? '',
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
// Live IPs from the router itself — useful so you can change the
|
||||||
|
// management IP to something the device actually owns instead of guessing.
|
||||||
|
const { data: ipRows, isLoading: ipsLoading, error: ipsError } = useQuery({
|
||||||
|
queryKey: ['device-ips', device.id],
|
||||||
|
queryFn: () => devicesApi.getIpAddresses(device.id).then((r) => r.data),
|
||||||
|
enabled: device.status === 'online',
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidateIps = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: { address: string; cidr: string; iface: string }[] = [];
|
||||||
|
for (const row of (ipRows ?? []) as IpAddress[]) {
|
||||||
|
if (!isSelectableIp(row)) continue;
|
||||||
|
const bare = addrWithoutMask(row.address);
|
||||||
|
if (seen.has(bare)) continue;
|
||||||
|
seen.add(bare);
|
||||||
|
out.push({ address: bare, cidr: row.address, iface: row.interface });
|
||||||
|
}
|
||||||
|
// Always include the current IP so it can be reselected
|
||||||
|
if (device.ip_address && !seen.has(device.ip_address)) {
|
||||||
|
out.unshift({ address: device.ip_address, cidr: device.ip_address, iface: '(current)' });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [ipRows, device.ip_address]);
|
||||||
|
|
||||||
|
const ipWasDetected = candidateIps.some((c) => c.address === form.ip_address);
|
||||||
|
|
||||||
|
useEffect(() => { setError(''); }, [form.ip_address, form.api_port, form.api_username, form.api_password]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.name || !form.ip_address || !form.api_username) {
|
||||||
|
setError('Name, IP address, and username are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: form.name,
|
||||||
|
ip_address: form.ip_address,
|
||||||
|
api_port: parseInt(form.api_port, 10) || 8728,
|
||||||
|
api_username: form.api_username,
|
||||||
|
ssh_port: parseInt(form.ssh_port, 10) || 22,
|
||||||
|
ssh_username: form.ssh_username || null,
|
||||||
|
device_type: form.device_type as DeviceType,
|
||||||
|
notes: form.notes,
|
||||||
|
};
|
||||||
|
if (form.api_password) payload.api_password = form.api_password;
|
||||||
|
if (form.ssh_password) payload.ssh_password = form.ssh_password;
|
||||||
|
await devicesApi.update(device.id, payload);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
|
setError(msg || 'Failed to update device');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Edit Device
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||||
|
{/* Basic info */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">Device Name *</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label flex items-center justify-between">
|
||||||
|
<span>Management IP *</span>
|
||||||
|
{device.status !== 'online' && (
|
||||||
|
<span className="text-[10px] font-normal text-gray-400 dark:text-slate-500 normal-case tracking-normal">
|
||||||
|
Device offline — IP list unavailable, enter manually
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.ip_address}
|
||||||
|
onChange={(e) => set('ip_address', e.target.value)}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
/>
|
||||||
|
{device.status === 'online' && (
|
||||||
|
<div className="mt-2 border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
|
<div className="px-3 py-1.5 bg-gray-50 dark:bg-slate-700/50 text-[11px] uppercase tracking-wide font-semibold text-gray-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
|
<Network className="w-3 h-3" />
|
||||||
|
IPs configured on this device
|
||||||
|
</div>
|
||||||
|
{ipsLoading ? (
|
||||||
|
<div className="px-3 py-3 text-xs text-gray-400 flex items-center gap-2">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" /> Fetching from router…
|
||||||
|
</div>
|
||||||
|
) : ipsError ? (
|
||||||
|
<div className="px-3 py-3 text-xs text-red-500">
|
||||||
|
Could not fetch IP list. You can still enter one manually.
|
||||||
|
</div>
|
||||||
|
) : candidateIps.length === 0 ? (
|
||||||
|
<div className="px-3 py-3 text-xs text-gray-400">
|
||||||
|
No usable IP addresses returned by the router.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-40 overflow-y-auto divide-y divide-gray-100 dark:divide-slate-700">
|
||||||
|
{candidateIps.map((c) => {
|
||||||
|
const selected = form.ip_address === c.address;
|
||||||
|
return (
|
||||||
|
<li key={`${c.address}-${c.iface}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('ip_address', c.address)}
|
||||||
|
className={
|
||||||
|
'w-full flex items-center justify-between px-3 py-1.5 text-xs text-left transition-colors ' +
|
||||||
|
(selected
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50 text-gray-700 dark:text-slate-300')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="font-mono">{c.cidr}</span>
|
||||||
|
<span className="text-gray-400 dark:text-slate-500">{c.iface}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{!ipsLoading && !ipsError && !ipWasDetected && form.ip_address && (
|
||||||
|
<div className="px-3 py-2 text-[11px] text-amber-600 dark:text-amber-400 border-t border-gray-100 dark:border-slate-700">
|
||||||
|
Entered IP isn't in the router's address list — double-check before saving.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">Device Type</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={form.device_type}
|
||||||
|
onChange={(e) => set('device_type', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="router">Router</option>
|
||||||
|
<option value="switch">Switch</option>
|
||||||
|
<option value="wireless_ap">Wireless AP</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API credentials */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||||
|
RouterOS API Credentials
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Username *</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.api_username}
|
||||||
|
onChange={(e) => set('api_username', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">API Port</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
value={form.api_port}
|
||||||
|
onChange={(e) => set('api_port', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">New Password (leave blank to keep existing)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={form.api_password}
|
||||||
|
onChange={(e) => set('api_password', e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SSH credentials (optional) */}
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider select-none">
|
||||||
|
SSH Credentials (optional, for backup/restore)
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">SSH Username</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.ssh_username}
|
||||||
|
onChange={(e) => set('ssh_username', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">SSH Port</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
value={form.ssh_port}
|
||||||
|
onChange={(e) => set('ssh_port', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">New SSH Password (leave blank to keep existing)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={form.ssh_password}
|
||||||
|
onChange={(e) => set('ssh_password', e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Notes</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={2}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => set('notes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<Loader2 className="w-4 h-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||||
|
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||||
|
Verifying connection with new settings…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={loading} className="btn-primary flex items-center gap-2">
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { X, Loader2, CheckCircle, AlertCircle, KeyRound } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
credentialPresetsApi,
|
||||||
|
devicesApi,
|
||||||
|
type DiscoveredDevice,
|
||||||
|
type DuplicateSerialError,
|
||||||
|
} from '../../services/api';
|
||||||
|
import type { DeviceType } from '../../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
discoveredDevices: DiscoveredDevice[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultItem = { ip: string; identity: string; ok: boolean; message: string };
|
||||||
|
|
||||||
|
function isDuplicateSerialError(v: unknown): v is DuplicateSerialError {
|
||||||
|
if (!v || typeof v !== 'object') return false;
|
||||||
|
const obj = v as { error?: unknown; code?: unknown; existing_device?: unknown };
|
||||||
|
return obj.error === 'duplicate_serial' && obj.code === 'duplicate_serial' && !!obj.existing_device;
|
||||||
|
}
|
||||||
|
export default function TryAllDiscoveredModal({ discoveredDevices, onClose, onSuccess }: Props) {
|
||||||
|
const [mode, setMode] = useState<'preset' | 'manual'>('preset');
|
||||||
|
const [presetId, setPresetId] = useState<number | null>(null);
|
||||||
|
const [manual, setManual] = useState({
|
||||||
|
api_username: 'admin',
|
||||||
|
api_password: '',
|
||||||
|
api_port: '8728',
|
||||||
|
ssh_username: '',
|
||||||
|
ssh_password: '',
|
||||||
|
ssh_port: '22',
|
||||||
|
device_type: 'router' as DeviceType,
|
||||||
|
});
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [results, setResults] = useState<ResultItem[] | null>(null);
|
||||||
|
const [cancelled, setCancelled] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<{ current: number; total: number; currentName: string }>({
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
currentName: '',
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const cancelRef = useRef(false);
|
||||||
|
|
||||||
|
const { data: presets = [] } = useQuery({
|
||||||
|
queryKey: ['credential-presets'],
|
||||||
|
queryFn: () => credentialPresetsApi.list().then((r) => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targets = useMemo(
|
||||||
|
() => discoveredDevices.filter((d) => d.address && d.address.trim().length > 0),
|
||||||
|
[discoveredDevices]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
setError('');
|
||||||
|
setCancelled(false);
|
||||||
|
cancelRef.current = false;
|
||||||
|
setResults(null);
|
||||||
|
if (!targets.length) {
|
||||||
|
setError('No discovered devices with a usable IP address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === 'preset' && !presetId) {
|
||||||
|
setError('Choose a credential preset or switch to manual.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === 'manual' && (!manual.api_username || !manual.api_password)) {
|
||||||
|
setError('Manual mode requires API username and password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
setProgress({ current: 0, total: targets.length, currentName: '' });
|
||||||
|
const out: ResultItem[] = [];
|
||||||
|
for (let i = 0; i < targets.length; i++) {
|
||||||
|
if (cancelRef.current) break;
|
||||||
|
const d = targets[i];
|
||||||
|
const name = d.identity || d.address;
|
||||||
|
setProgress({ current: i + 1, total: targets.length, currentName: name });
|
||||||
|
const payload =
|
||||||
|
mode === 'preset'
|
||||||
|
? {
|
||||||
|
name,
|
||||||
|
ip_address: d.address,
|
||||||
|
credential_preset_id: presetId!,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name,
|
||||||
|
ip_address: d.address,
|
||||||
|
api_username: manual.api_username,
|
||||||
|
api_password: manual.api_password,
|
||||||
|
api_port: parseInt(manual.api_port, 10) || 8728,
|
||||||
|
ssh_username: manual.ssh_username || undefined,
|
||||||
|
ssh_password: manual.ssh_password || undefined,
|
||||||
|
ssh_port: parseInt(manual.ssh_port, 10) || 22,
|
||||||
|
device_type: manual.device_type,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await devicesApi.create(payload);
|
||||||
|
out.push({ ip: d.address, identity: name, ok: true, message: 'Added' });
|
||||||
|
setResults([...out]);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const data = (err as { response?: { data?: unknown } })?.response?.data;
|
||||||
|
|
||||||
|
const msg = isDuplicateSerialError(data)
|
||||||
|
? `Duplicate serial mismatch: candidate ${data.candidate.serial_number}, existing ${data.existing_device.serial_number}. Review manually before combining.`
|
||||||
|
: ((data as { message?: string; error?: string } | undefined)?.message ||
|
||||||
|
(typeof (data as { error?: unknown } | undefined)?.error === 'string'
|
||||||
|
? String((data as { error?: string }).error)
|
||||||
|
: '') ||
|
||||||
|
'Failed');
|
||||||
|
out.push({ ip: d.address, identity: name, ok: false, message: msg });
|
||||||
|
setResults([...out]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResults(out);
|
||||||
|
setRunning(false);
|
||||||
|
setProgress((p) => ({ ...p, currentName: '' }));
|
||||||
|
if (cancelRef.current) setCancelled(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const okCount = results?.filter((r) => r.ok).length ?? 0;
|
||||||
|
const failCount = results?.filter((r) => !r.ok).length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="card w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Try All Discovered Devices</h2>
|
||||||
|
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-slate-300">
|
||||||
|
Attempt to add <strong>{targets.length}</strong> discovered device(s) in one run.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('preset')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm border ${mode === 'preset' ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-600 dark:text-slate-300 border-gray-300 dark:border-slate-600'}`}
|
||||||
|
>
|
||||||
|
Use Preset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('manual')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm border ${mode === 'manual' ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-600 dark:text-slate-300 border-gray-300 dark:border-slate-600'}`}
|
||||||
|
>
|
||||||
|
Enter Manually
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'preset' ? (
|
||||||
|
<div>
|
||||||
|
<label className="label flex items-center gap-1.5"><KeyRound className="w-3.5 h-3.5" />Credential Preset</label>
|
||||||
|
<select className="input" value={presetId ?? ''} onChange={(e) => setPresetId(e.target.value ? parseInt(e.target.value, 10) : null)}>
|
||||||
|
<option value="">Select preset...</option>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} ({p.api_username}{p.ssh_username ? ` · SSH ${p.ssh_username}` : ''})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">API Username *</label>
|
||||||
|
<input className="input" value={manual.api_username} onChange={(e) => setManual((m) => ({ ...m, api_username: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">API Port</label>
|
||||||
|
<input className="input" type="number" value={manual.api_port} onChange={(e) => setManual((m) => ({ ...m, api_port: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">API Password *</label>
|
||||||
|
<input className="input" type="password" value={manual.api_password} onChange={(e) => setManual((m) => ({ ...m, api_password: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">SSH Username</label>
|
||||||
|
<input className="input" value={manual.ssh_username} onChange={(e) => setManual((m) => ({ ...m, ssh_username: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">SSH Port</label>
|
||||||
|
<input className="input" type="number" value={manual.ssh_port} onChange={(e) => setManual((m) => ({ ...m, ssh_port: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">SSH Password</label>
|
||||||
|
<input className="input" type="password" value={manual.ssh_password} onChange={(e) => setManual((m) => ({ ...m, ssh_password: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{running && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm text-gray-700 dark:text-slate-300">
|
||||||
|
Trying {progress.current}/{progress.total}
|
||||||
|
{progress.currentName && (
|
||||||
|
<>
|
||||||
|
: <span className="font-medium">{progress.currentName}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all"
|
||||||
|
style={{ width: `${progress.total ? Math.round((progress.current / progress.total) * 100) : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { cancelRef.current = true; }}
|
||||||
|
className="btn-secondary text-xs py-1 px-2"
|
||||||
|
>
|
||||||
|
Stop / Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm text-gray-700 dark:text-slate-300">
|
||||||
|
{running ? 'Live results:' : cancelled ? 'Cancelled:' : 'Done:'}{' '}
|
||||||
|
<span className="text-green-600 dark:text-green-400">{okCount} succeeded</span>,{' '}
|
||||||
|
<span className="text-red-600 dark:text-red-400">{failCount} failed</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 overflow-y-auto border border-gray-200 dark:border-slate-700 rounded-lg">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-50 dark:bg-slate-700/50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="table-header px-3 py-2 text-left">Device</th>
|
||||||
|
<th className="table-header px-3 py-2 text-left">IP</th>
|
||||||
|
<th className="table-header px-3 py-2 text-left">Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||||
|
{results.map((r) => (
|
||||||
|
<tr key={`${r.ip}-${r.identity}`}>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-slate-300">{r.identity}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-gray-500 dark:text-slate-400">{r.ip}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{r.ok ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" /> {r.message}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5" /> {r.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className="btn-secondary">Close</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={running}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
|
||||||
|
{running ? 'Trying...' : `Try All (${targets.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Plus, Pencil, Trash2, KeyRound, X, AlertCircle, CheckCircle, Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
credentialPresetsApi,
|
||||||
|
type CredentialPreset,
|
||||||
|
type CredentialPresetInput,
|
||||||
|
} from '../../services/api';
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string;
|
||||||
|
api_username: string;
|
||||||
|
api_password: string;
|
||||||
|
api_port: string;
|
||||||
|
ssh_username: string;
|
||||||
|
ssh_password: string;
|
||||||
|
ssh_port: string;
|
||||||
|
notes: string;
|
||||||
|
clear_ssh_password: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormState = {
|
||||||
|
name: '',
|
||||||
|
api_username: 'admin',
|
||||||
|
api_password: '',
|
||||||
|
api_port: '8728',
|
||||||
|
ssh_username: '',
|
||||||
|
ssh_password: '',
|
||||||
|
ssh_port: '22',
|
||||||
|
notes: '',
|
||||||
|
clear_ssh_password: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function toInput(f: FormState, isEdit: boolean): CredentialPresetInput {
|
||||||
|
const input: CredentialPresetInput = {
|
||||||
|
name: f.name || undefined,
|
||||||
|
api_username: f.api_username || undefined,
|
||||||
|
api_port: f.api_port ? parseInt(f.api_port, 10) : null,
|
||||||
|
ssh_username: f.ssh_username || null,
|
||||||
|
ssh_port: f.ssh_port ? parseInt(f.ssh_port, 10) : null,
|
||||||
|
notes: f.notes || null,
|
||||||
|
};
|
||||||
|
if (f.api_password) input.api_password = f.api_password;
|
||||||
|
if (f.ssh_password) input.ssh_password = f.ssh_password;
|
||||||
|
else if (isEdit && f.clear_ssh_password) input.clear_ssh_password = true;
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CredentialPresetsSettings({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data: presets = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['credential-presets'],
|
||||||
|
queryFn: () => credentialPresetsApi.list().then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [modal, setModal] = useState<null | { mode: 'add' } | { mode: 'edit'; id: number }>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
||||||
|
setForm((s) => ({ ...s, [k]: v }));
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setError('');
|
||||||
|
setModal({ mode: 'add' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (p: CredentialPreset) => {
|
||||||
|
setForm({
|
||||||
|
name: p.name,
|
||||||
|
api_username: p.api_username,
|
||||||
|
api_password: '',
|
||||||
|
api_port: p.api_port != null ? String(p.api_port) : '',
|
||||||
|
ssh_username: p.ssh_username ?? '',
|
||||||
|
ssh_password: '',
|
||||||
|
ssh_port: p.ssh_port != null ? String(p.ssh_port) : '',
|
||||||
|
notes: p.notes ?? '',
|
||||||
|
clear_ssh_password: false,
|
||||||
|
});
|
||||||
|
setError('');
|
||||||
|
setModal({ mode: 'edit', id: p.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!modal) return;
|
||||||
|
if (modal.mode === 'add') {
|
||||||
|
if (!form.name || !form.api_username || !form.api_password) {
|
||||||
|
throw new Error('Name, API username and password are required');
|
||||||
|
}
|
||||||
|
await credentialPresetsApi.create(toInput(form, false));
|
||||||
|
} else {
|
||||||
|
await credentialPresetsApi.update(modal.id, toInput(form, true));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['credential-presets'] });
|
||||||
|
setModal(null);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
|
(err as Error)?.message ?? 'Failed to save preset';
|
||||||
|
setError(msg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => credentialPresetsApi.delete(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['credential-presets'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-1">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<KeyRound className="w-4 h-4 text-blue-500" />
|
||||||
|
Device Credential Presets
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||||
|
Reusable RouterOS API & SSH credential sets. When adding a
|
||||||
|
discovered device you can pick a preset instead of retyping the
|
||||||
|
same username and password. Passwords are encrypted at rest and
|
||||||
|
never returned to the browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
className="btn-primary text-xs flex items-center gap-1 flex-shrink-0"
|
||||||
|
onClick={openAdd}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Add Preset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 text-sm text-gray-400 flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading presets…
|
||||||
|
</div>
|
||||||
|
) : presets.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-gray-400 dark:text-slate-500 text-center">
|
||||||
|
No credential presets yet.
|
||||||
|
{isAdmin && ' Click "Add Preset" to create your first one.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 dark:border-slate-700">
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">API</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">SSH</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">Notes</th>
|
||||||
|
<th className="table-header px-4 py-2.5 w-24" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||||
|
{presets.map((p) => (
|
||||||
|
<tr key={p.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||||
|
{p.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||||
|
<div className="font-mono">{p.api_username}</div>
|
||||||
|
<div className="text-gray-400 dark:text-slate-500">
|
||||||
|
port {p.api_port ?? 8728}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||||
|
{p.ssh_username ? (
|
||||||
|
<>
|
||||||
|
<div className="font-mono">{p.ssh_username}</div>
|
||||||
|
<div className="text-gray-400 dark:text-slate-500">
|
||||||
|
port {p.ssh_port ?? 22}
|
||||||
|
{p.has_ssh_password ? '' : ' · no password'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300 dark:text-slate-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 max-w-xs truncate">
|
||||||
|
{p.notes || <span className="text-gray-300 dark:text-slate-600">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(p)}
|
||||||
|
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||||
|
title="Edit preset"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete credential preset "${p.name}"? Devices already using these credentials keep working — presets are not linked after use.`)) {
|
||||||
|
deleteMutation.mutate(p.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
|
title="Delete preset"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{modal.mode === 'add' ? 'Add Credential Preset' : 'Edit Credential Preset'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setModal(null)}
|
||||||
|
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Preset Name *</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => set('name', e.target.value)}
|
||||||
|
placeholder="e.g. Default MikroTik (admin)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||||
|
RouterOS API
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Username *</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.api_username}
|
||||||
|
onChange={(e) => set('api_username', e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">API Port</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
value={form.api_port}
|
||||||
|
onChange={(e) => set('api_port', e.target.value)}
|
||||||
|
placeholder="8728"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">
|
||||||
|
Password {modal.mode === 'edit' && '(leave blank to keep current)'}
|
||||||
|
{modal.mode === 'add' && ' *'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={form.api_password}
|
||||||
|
onChange={(e) => set('api_password', e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={modal.mode === 'edit' ? '••••••••' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||||
|
SSH (optional, for backups)
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">Username</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.ssh_username}
|
||||||
|
onChange={(e) => set('ssh_username', e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">SSH Port</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
value={form.ssh_port}
|
||||||
|
onChange={(e) => set('ssh_port', e.target.value)}
|
||||||
|
placeholder="22"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="label">
|
||||||
|
Password {modal.mode === 'edit' && '(leave blank to keep current)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={form.ssh_password}
|
||||||
|
onChange={(e) => set('ssh_password', e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{modal.mode === 'edit' && (
|
||||||
|
<label className="flex items-center gap-1.5 mt-2 text-xs text-gray-500 dark:text-slate-400 select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={form.clear_ssh_password}
|
||||||
|
disabled={!!form.ssh_password}
|
||||||
|
onChange={(e) => set('clear_ssh_password', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Remove saved SSH password
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Notes</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={2}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => set('notes', e.target.value)}
|
||||||
|
placeholder="Optional — e.g. which sites these credentials apply to"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setModal(null)} className="btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className={clsx('btn-primary flex items-center gap-2')}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{modal.mode === 'add' ? 'Create Preset' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,6 +77,47 @@
|
|||||||
@apply block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1;
|
@apply block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* React Flow default controls are light-theme SVGs; fix contrast in dark mode */
|
||||||
|
.topology-reactflow.dark .react-flow__controls {
|
||||||
|
@apply border-slate-600 shadow-lg;
|
||||||
|
}
|
||||||
|
.topology-reactflow.dark .react-flow__controls-button {
|
||||||
|
@apply bg-slate-600 border-slate-500;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
.topology-reactflow.dark .react-flow__controls-button:hover {
|
||||||
|
@apply bg-slate-500 border-slate-400;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.topology-reactflow.dark .react-flow__controls-button svg {
|
||||||
|
fill: #f1f5f9;
|
||||||
|
stroke: #f1f5f9;
|
||||||
|
}
|
||||||
|
.topology-reactflow.dark .react-flow__controls-button:disabled {
|
||||||
|
@apply opacity-40;
|
||||||
|
}
|
||||||
|
.topology-reactflow.dark .react-flow__minimap {
|
||||||
|
@apply bg-slate-700 border border-slate-600;
|
||||||
|
}
|
||||||
|
.topology-reactflow.dark .react-flow__minimap-mask {
|
||||||
|
fill: rgb(15 23 42 / 0.65);
|
||||||
|
}
|
||||||
|
.topology-reactflow.dark .react-flow__minimap-node {
|
||||||
|
@apply fill-slate-500 stroke-slate-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topology-reactflow {
|
||||||
|
--topology-edge-label: #64748b;
|
||||||
|
--topology-seg-edge-label: #b45309;
|
||||||
|
}
|
||||||
|
.dark .topology-reactflow {
|
||||||
|
--topology-edge-label: #cbd5e1;
|
||||||
|
--topology-seg-edge-label: #fcd34d;
|
||||||
|
}
|
||||||
|
.dark .topology-reactflow .react-flow__edge-textbg {
|
||||||
|
fill: rgb(30 41 59 / 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
.table-header {
|
.table-header {
|
||||||
@apply text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider;
|
@apply text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,65 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, RefreshCw, Router, Wifi, Trash2, ChevronRight, Search, Radar, ArrowUpCircle } from 'lucide-react';
|
import {
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Router,
|
||||||
|
Wifi,
|
||||||
|
Trash2,
|
||||||
|
ChevronRight,
|
||||||
|
Search,
|
||||||
|
Radar,
|
||||||
|
ArrowUpCircle,
|
||||||
|
Pencil,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
} from 'lucide-react';
|
||||||
import { devicesApi, topologyApi } from '../services/api';
|
import { devicesApi, topologyApi } from '../services/api';
|
||||||
import type { Device } from '../types';
|
import type { Device } from '../types';
|
||||||
import type { DiscoveredDevice } from '../services/api';
|
import type { DiscoveredDevice } from '../services/api';
|
||||||
import { useCanWrite } from '../hooks/useCanWrite';
|
import { useCanWrite } from '../hooks/useCanWrite';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import AddDeviceModal from '../components/devices/AddDeviceModal';
|
import AddDeviceModal from '../components/devices/AddDeviceModal';
|
||||||
|
import EditDeviceModal from '../components/devices/EditDeviceModal';
|
||||||
|
import TryAllDiscoveredModal from '../components/devices/TryAllDiscoveredModal';
|
||||||
|
|
||||||
|
type DeviceSortKey = 'name' | 'ip_address' | 'model' | 'ros_version' | 'status' | 'last_seen';
|
||||||
|
type DiscoveredSortKey = 'identity' | 'address' | 'mac_address' | 'seen_by' | 'discovered_at';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
dir,
|
||||||
|
onClick,
|
||||||
|
align = 'left',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
dir: SortDir;
|
||||||
|
onClick: () => void;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center gap-1.5 text-inherit hover:text-gray-900 dark:hover:text-white transition-colors',
|
||||||
|
align === 'center' && 'justify-center',
|
||||||
|
align === 'right' && 'justify-end'
|
||||||
|
)}
|
||||||
|
title={`Sort by ${label}`}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{active ? (dir === 'asc' ? <ArrowUp className="w-3.5 h-3.5" /> : <ArrowDown className="w-3.5 h-3.5" />) : (
|
||||||
|
<ArrowUpDown className="w-3.5 h-3.5 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatusDot({ status }: { status: Device['status'] }) {
|
function StatusDot({ status }: { status: Device['status'] }) {
|
||||||
return (
|
return (
|
||||||
@@ -43,9 +95,17 @@ export default function DevicesPage() {
|
|||||||
const canWrite = useCanWrite();
|
const canWrite = useCanWrite();
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [addPrefill, setAddPrefill] = useState<{ name?: string; ip_address?: string } | undefined>();
|
const [addPrefill, setAddPrefill] = useState<{ name?: string; ip_address?: string } | undefined>();
|
||||||
|
const [editDevice, setEditDevice] = useState<Device | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [hideDuplicates, setHideDuplicates] = useState(true);
|
||||||
|
const [showTryAllModal, setShowTryAllModal] = useState(false);
|
||||||
|
const [deviceSort, setDeviceSort] = useState<{ key: DeviceSortKey; dir: SortDir }>({ key: 'name', dir: 'asc' });
|
||||||
|
const [discoveredSort, setDiscoveredSort] = useState<{ key: DiscoveredSortKey; dir: SortDir }>({
|
||||||
|
key: 'discovered_at',
|
||||||
|
dir: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
const { data: devices = [], isLoading } = useQuery({
|
const { data: devices = [], isLoading } = useQuery({
|
||||||
queryKey: ['devices'],
|
queryKey: ['devices'],
|
||||||
@@ -72,13 +132,75 @@ export default function DevicesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const filtered = devices.filter(
|
const filtered = useMemo(() => {
|
||||||
(d) =>
|
const base = devices.filter(
|
||||||
!search ||
|
(d) =>
|
||||||
d.name.toLowerCase().includes(search.toLowerCase()) ||
|
!search ||
|
||||||
d.ip_address.includes(search) ||
|
d.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
d.model?.toLowerCase().includes(search.toLowerCase())
|
d.ip_address.includes(search) ||
|
||||||
|
d.model?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
const sorted = [...base].sort((a, b) => {
|
||||||
|
const val = (x: Device): string | number => {
|
||||||
|
switch (deviceSort.key) {
|
||||||
|
case 'name': return x.name || '';
|
||||||
|
case 'ip_address': return x.ip_address || '';
|
||||||
|
case 'model': return x.model || '';
|
||||||
|
case 'ros_version': return x.ros_version || '';
|
||||||
|
case 'status': return x.status || '';
|
||||||
|
case 'last_seen': return x.last_seen ? new Date(x.last_seen).getTime() : 0;
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const av = val(a);
|
||||||
|
const bv = val(b);
|
||||||
|
const cmp = typeof av === 'number' && typeof bv === 'number'
|
||||||
|
? av - bv
|
||||||
|
: String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
return deviceSort.dir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [devices, search, deviceSort]);
|
||||||
|
|
||||||
|
const discoveredList = discovered as DiscoveredDevice[];
|
||||||
|
const duplicateCount = useMemo(
|
||||||
|
() => discoveredList.filter((d) => d.duplicate_of_device_id != null).length,
|
||||||
|
[discoveredList]
|
||||||
);
|
);
|
||||||
|
const visibleDiscovered = useMemo(() => {
|
||||||
|
const base = hideDuplicates ? discoveredList.filter((d) => d.duplicate_of_device_id == null) : discoveredList;
|
||||||
|
const sorted = [...base].sort((a, b) => {
|
||||||
|
const val = (x: DiscoveredDevice): string | number => {
|
||||||
|
switch (discoveredSort.key) {
|
||||||
|
case 'identity': return x.identity || '';
|
||||||
|
case 'address': return x.address || '';
|
||||||
|
case 'mac_address': return x.mac_address || '';
|
||||||
|
case 'seen_by': return x.seen_by || '';
|
||||||
|
case 'discovered_at': return x.discovered_at ? new Date(x.discovered_at).getTime() : 0;
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const av = val(a);
|
||||||
|
const bv = val(b);
|
||||||
|
const cmp = typeof av === 'number' && typeof bv === 'number'
|
||||||
|
? av - bv
|
||||||
|
: String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
return discoveredSort.dir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [discoveredList, hideDuplicates, discoveredSort]);
|
||||||
|
|
||||||
|
const flipSort = <K extends string>(
|
||||||
|
current: { key: K; dir: SortDir },
|
||||||
|
key: K,
|
||||||
|
set: (v: { key: K; dir: SortDir }) => void
|
||||||
|
) => {
|
||||||
|
if (current.key === key) {
|
||||||
|
set({ key, dir: current.dir === 'asc' ? 'desc' : 'asc' });
|
||||||
|
} else {
|
||||||
|
set({ key, dir: 'asc' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -158,12 +280,54 @@ export default function DevicesPage() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||||
<th className="table-header px-4 py-3">Device</th>
|
<th className="table-header px-4 py-3">
|
||||||
<th className="table-header px-4 py-3">IP Address</th>
|
<SortableHeader
|
||||||
<th className="table-header px-4 py-3">Model</th>
|
label="Device"
|
||||||
<th className="table-header px-4 py-3">ROS Version</th>
|
active={deviceSort.key === 'name'}
|
||||||
<th className="table-header px-4 py-3">Status</th>
|
dir={deviceSort.dir}
|
||||||
<th className="table-header px-4 py-3">Last Seen</th>
|
onClick={() => flipSort(deviceSort, 'name', setDeviceSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="table-header px-4 py-3">
|
||||||
|
<SortableHeader
|
||||||
|
label="IP Address"
|
||||||
|
active={deviceSort.key === 'ip_address'}
|
||||||
|
dir={deviceSort.dir}
|
||||||
|
onClick={() => flipSort(deviceSort, 'ip_address', setDeviceSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="table-header px-4 py-3">
|
||||||
|
<SortableHeader
|
||||||
|
label="Model"
|
||||||
|
active={deviceSort.key === 'model'}
|
||||||
|
dir={deviceSort.dir}
|
||||||
|
onClick={() => flipSort(deviceSort, 'model', setDeviceSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="table-header px-4 py-3">
|
||||||
|
<SortableHeader
|
||||||
|
label="ROS Version"
|
||||||
|
active={deviceSort.key === 'ros_version'}
|
||||||
|
dir={deviceSort.dir}
|
||||||
|
onClick={() => flipSort(deviceSort, 'ros_version', setDeviceSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="table-header px-4 py-3">
|
||||||
|
<SortableHeader
|
||||||
|
label="Status"
|
||||||
|
active={deviceSort.key === 'status'}
|
||||||
|
dir={deviceSort.dir}
|
||||||
|
onClick={() => flipSort(deviceSort, 'status', setDeviceSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="table-header px-4 py-3">
|
||||||
|
<SortableHeader
|
||||||
|
label="Last Seen"
|
||||||
|
active={deviceSort.key === 'last_seen'}
|
||||||
|
dir={deviceSort.dir}
|
||||||
|
onClick={() => flipSort(deviceSort, 'last_seen', setDeviceSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th className="table-header px-4 py-3 w-28">Actions</th>
|
<th className="table-header px-4 py-3 w-28">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -231,6 +395,13 @@ export default function DevicesPage() {
|
|||||||
className={clsx('w-3.5 h-3.5', syncMutation.isPending && 'animate-spin')}
|
className={clsx('w-3.5 h-3.5', syncMutation.isPending && 'animate-spin')}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditDevice(device)}
|
||||||
|
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||||
|
title="Edit device"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
{deleteConfirm === device.id ? (
|
{deleteConfirm === device.id ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -269,9 +440,9 @@ export default function DevicesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Discovered (unmanaged) MikroTik neighbors */}
|
{/* Discovered (unmanaged) MikroTik neighbors */}
|
||||||
{discovered.length > 0 && (
|
{discoveredList.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Radar className="w-4 h-4 text-amber-500" />
|
<Radar className="w-4 h-4 text-amber-500" />
|
||||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-300">
|
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-300">
|
||||||
Discovered Devices
|
Discovered Devices
|
||||||
@@ -279,25 +450,116 @@ export default function DevicesPage() {
|
|||||||
<span className="text-xs text-gray-400 dark:text-slate-500">
|
<span className="text-xs text-gray-400 dark:text-slate-500">
|
||||||
— Mikrotik neighbors seen by your managed devices, not yet added to management
|
— Mikrotik neighbors seen by your managed devices, not yet added to management
|
||||||
</span>
|
</span>
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
'ml-auto flex items-center gap-2 text-xs select-none',
|
||||||
|
duplicateCount === 0
|
||||||
|
? 'text-gray-300 dark:text-slate-600 cursor-not-allowed'
|
||||||
|
: 'text-gray-600 dark:text-slate-400 cursor-pointer'
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
duplicateCount === 0
|
||||||
|
? 'No duplicates of already-managed devices detected'
|
||||||
|
: `${duplicateCount} row(s) match a device that's already managed (by MAC, IP, or identity)`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-slate-600"
|
||||||
|
checked={hideDuplicates}
|
||||||
|
disabled={duplicateCount === 0}
|
||||||
|
onChange={(e) => setHideDuplicates(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Hide duplicates of managed devices
|
||||||
|
{duplicateCount > 0 && (
|
||||||
|
<span className="text-gray-400 dark:text-slate-500">({duplicateCount})</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{canWrite && visibleDiscovered.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTryAllModal(true)}
|
||||||
|
className="btn-primary text-xs py-1.5 px-3"
|
||||||
|
title="Try adding all discovered devices with one credential method"
|
||||||
|
>
|
||||||
|
Try All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{visibleDiscovered.length === 0 ? (
|
||||||
|
<div className="card p-4 text-xs text-gray-500 dark:text-slate-400 text-center">
|
||||||
|
All {discoveredList.length} discovered neighbor(s) are already managed devices.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||||
<th className="table-header px-4 py-2.5 text-left">Identity</th>
|
<th className="table-header px-4 py-2.5 text-left">
|
||||||
<th className="table-header px-4 py-2.5 text-left">IP Address</th>
|
<SortableHeader
|
||||||
<th className="table-header px-4 py-2.5 text-left">MAC Address</th>
|
label="Identity"
|
||||||
<th className="table-header px-4 py-2.5 text-left">Seen By</th>
|
active={discoveredSort.key === 'identity'}
|
||||||
<th className="table-header px-4 py-2.5 text-left">Last Seen</th>
|
dir={discoveredSort.dir}
|
||||||
|
onClick={() => flipSort(discoveredSort, 'identity', setDiscoveredSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">
|
||||||
|
<SortableHeader
|
||||||
|
label="IP Address"
|
||||||
|
active={discoveredSort.key === 'address'}
|
||||||
|
dir={discoveredSort.dir}
|
||||||
|
onClick={() => flipSort(discoveredSort, 'address', setDiscoveredSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">
|
||||||
|
<SortableHeader
|
||||||
|
label="MAC Address"
|
||||||
|
active={discoveredSort.key === 'mac_address'}
|
||||||
|
dir={discoveredSort.dir}
|
||||||
|
onClick={() => flipSort(discoveredSort, 'mac_address', setDiscoveredSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">
|
||||||
|
<SortableHeader
|
||||||
|
label="Seen By"
|
||||||
|
active={discoveredSort.key === 'seen_by'}
|
||||||
|
dir={discoveredSort.dir}
|
||||||
|
onClick={() => flipSort(discoveredSort, 'seen_by', setDiscoveredSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">
|
||||||
|
<SortableHeader
|
||||||
|
label="Last Seen"
|
||||||
|
active={discoveredSort.key === 'discovered_at'}
|
||||||
|
dir={discoveredSort.dir}
|
||||||
|
onClick={() => flipSort(discoveredSort, 'discovered_at', setDiscoveredSort)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th className="table-header px-4 py-2.5 w-36" />
|
<th className="table-header px-4 py-2.5 w-36" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||||
{(discovered as DiscoveredDevice[]).map((d, i) => (
|
{visibleDiscovered.map((d, i) => (
|
||||||
<tr key={d.mac_address || d.address || i} className="hover:bg-amber-50/50 dark:hover:bg-amber-900/10">
|
<tr
|
||||||
|
key={d.mac_address || d.address || i}
|
||||||
|
className={clsx(
|
||||||
|
'hover:bg-amber-50/50 dark:hover:bg-amber-900/10',
|
||||||
|
d.duplicate_of_device_id != null && 'opacity-70'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||||
{d.identity || '—'}
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{d.identity || '—'}</span>
|
||||||
|
{d.duplicate_of_device_id != null && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||||
|
title={`Matches managed device: ${d.duplicate_of_device_name ?? ''}`}
|
||||||
|
>
|
||||||
|
Already managed
|
||||||
|
{d.duplicate_of_device_name ? ` · ${d.duplicate_of_device_name}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400">
|
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400">
|
||||||
{d.address ? (
|
{d.address ? (
|
||||||
@@ -318,7 +580,7 @@ export default function DevicesPage() {
|
|||||||
{new Date(d.discovered_at).toLocaleString()}
|
{new Date(d.discovered_at).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{canWrite && (
|
{canWrite && d.duplicate_of_device_id == null && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddPrefill({ name: d.identity || '', ip_address: d.address });
|
setAddPrefill({ name: d.identity || '', ip_address: d.address });
|
||||||
@@ -337,6 +599,7 @@ export default function DevicesPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -351,6 +614,30 @@ export default function DevicesPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editDevice && (
|
||||||
|
<EditDeviceModal
|
||||||
|
device={editDevice}
|
||||||
|
onClose={() => setEditDevice(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditDevice(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices-discovered'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTryAllModal && (
|
||||||
|
<TryAllDiscoveredModal
|
||||||
|
discoveredDevices={visibleDiscovered.filter((d) => d.duplicate_of_device_id == null && !!d.address)}
|
||||||
|
onClose={() => setShowTryAllModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowTryAllModal(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices-discovered'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
|
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
|
||||||
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send,
|
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { settingsApi, authApi, certApi, alertsApi } from '../services/api';
|
import { settingsApi, authApi, certApi, alertsApi } from '../services/api';
|
||||||
import type { CertInfo, AlertRule, AlertChannel } from '../services/api';
|
import type { CertInfo, AlertRule, AlertChannel } from '../services/api';
|
||||||
@@ -10,6 +10,7 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { useThemeStore } from '../store/themeStore';
|
import { useThemeStore } from '../store/themeStore';
|
||||||
import type { User, UserRole } from '../types';
|
import type { User, UserRole } from '../types';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings';
|
||||||
|
|
||||||
const ROLE_META: Record<UserRole, { label: string; color: string; desc: string }> = {
|
const ROLE_META: Record<UserRole, { label: string; color: string; desc: string }> = {
|
||||||
admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' },
|
admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' },
|
||||||
@@ -41,7 +42,7 @@ export default function SettingsPage() {
|
|||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
const canWrite = user?.role !== 'viewer';
|
const canWrite = user?.role !== 'viewer';
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'security' | 'certificate' | 'alerting'>('general');
|
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting'>('general');
|
||||||
|
|
||||||
// ─── App settings ─────────────────────────────────────────────────────────
|
// ─── App settings ─────────────────────────────────────────────────────────
|
||||||
const { data: settings = {} } = useQuery({
|
const { data: settings = {} } = useQuery({
|
||||||
@@ -279,6 +280,7 @@ export default function SettingsPage() {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'general' as const, label: 'General', icon: Settings },
|
{ key: 'general' as const, label: 'General', icon: Settings },
|
||||||
{ key: 'users' as const, label: 'Users & Roles', icon: Users },
|
{ key: 'users' as const, label: 'Users & Roles', icon: Users },
|
||||||
|
{ key: 'credentials' as const, label: 'Device Credentials', icon: KeyRound },
|
||||||
{ key: 'security' as const, label: 'My Password', icon: Key },
|
{ key: 'security' as const, label: 'My Password', icon: Key },
|
||||||
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
||||||
{ key: 'alerting' as const, label: 'Alerting', icon: Bell },
|
{ key: 'alerting' as const, label: 'Alerting', icon: Bell },
|
||||||
@@ -618,6 +620,11 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Device Credential Presets ── */}
|
||||||
|
{activeTab === 'credentials' && (
|
||||||
|
<CredentialPresetsSettings isAdmin={isAdmin} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── My Password ── */}
|
{/* ── My Password ── */}
|
||||||
{activeTab === 'security' && (
|
{activeTab === 'security' && (
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
@@ -20,6 +20,7 @@ import { RefreshCw, GitBranch } from 'lucide-react';
|
|||||||
import { topologyApi } from '../services/api';
|
import { topologyApi } from '../services/api';
|
||||||
import type { TopologyDevice, TopologyLink, ExternalTopologyNode } from '../types';
|
import type { TopologyDevice, TopologyLink, ExternalTopologyNode } from '../types';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { useThemeStore } from '../store/themeStore';
|
||||||
|
|
||||||
// helper to derive device class from LLDP capabilities
|
// helper to derive device class from LLDP capabilities
|
||||||
function capsLabel(caps: string | undefined): string {
|
function capsLabel(caps: string | undefined): string {
|
||||||
@@ -136,15 +137,115 @@ function ExternalNode({ data }: { data: Record<string, unknown> }) {
|
|||||||
|
|
||||||
const nodeTypes = { deviceNode: DeviceNode, externalNode: ExternalNode };
|
const nodeTypes = { deviceNode: DeviceNode, externalNode: ExternalNode };
|
||||||
|
|
||||||
|
/** Layout box (React Flow uses top-left origin for `position`). */
|
||||||
const NODE_W = 160;
|
const NODE_W = 160;
|
||||||
const NODE_H = 80;
|
const NODE_H = 80;
|
||||||
const H_GAP = 60;
|
/** Conservative hit box so labels / padding do not overlap adjacent nodes */
|
||||||
const V_GAP = 90;
|
const NODE_LAYOUT_W = 220;
|
||||||
|
const NODE_LAYOUT_H = 110;
|
||||||
|
const H_GAP = 60; // horizontal spacing between sibling nodes
|
||||||
|
const V_GAP = 90; // vertical spacing between tree levels
|
||||||
|
const COMPONENT_GAP = 120; // gap between disconnected subgraphs
|
||||||
|
/** Horizontal slot for tree layout — use layout box width so siblings start far enough apart */
|
||||||
|
const NODE_SLOT = NODE_LAYOUT_W + H_GAP;
|
||||||
|
const OVERLAP_SEP = 16; // extra gap when separating overlapping nodes
|
||||||
|
|
||||||
|
export interface TopologyLayoutOptions {
|
||||||
|
/** When true, nodes at the same tree depth share one horizontal row (aligned Y). */
|
||||||
|
alignByDepth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectsOverlap(
|
||||||
|
a: { x: number; y: number; w: number; h: number },
|
||||||
|
b: { x: number; y: number; w: number; h: number }
|
||||||
|
): boolean {
|
||||||
|
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push nodes apart until bounding boxes (layout size) no longer overlap. */
|
||||||
|
function resolveOverlaps(
|
||||||
|
positions: Map<string, { x: number; y: number }>,
|
||||||
|
opts?: { maxIterations?: number }
|
||||||
|
): void {
|
||||||
|
const maxIt = opts?.maxIterations ?? 100;
|
||||||
|
const ids = [...positions.keys()];
|
||||||
|
if (ids.length < 2) return;
|
||||||
|
|
||||||
|
const w = NODE_LAYOUT_W;
|
||||||
|
const h = NODE_LAYOUT_H;
|
||||||
|
|
||||||
|
for (let round = 0; round < maxIt; round++) {
|
||||||
|
let moved = false;
|
||||||
|
const boxes = ids.map((id) => {
|
||||||
|
const p = positions.get(id)!;
|
||||||
|
return { id, x: p.x, y: p.y, w, h };
|
||||||
|
});
|
||||||
|
boxes.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
|
||||||
|
|
||||||
|
for (let i = 0; i < boxes.length; i++) {
|
||||||
|
for (let j = i + 1; j < boxes.length; j++) {
|
||||||
|
const A = boxes[i];
|
||||||
|
const B = boxes[j];
|
||||||
|
if (!rectsOverlap(A, B)) continue;
|
||||||
|
|
||||||
|
const dx = Math.abs((B.x + B.w / 2) - (A.x + A.w / 2));
|
||||||
|
const dy = Math.abs((B.y + B.h / 2) - (A.y + A.h / 2));
|
||||||
|
const pushDown = dy <= dx * 0.85;
|
||||||
|
|
||||||
|
if (pushDown) {
|
||||||
|
const newY = A.y + A.h + OVERLAP_SEP;
|
||||||
|
if (newY !== B.y) {
|
||||||
|
positions.set(B.id, { x: B.x, y: newY });
|
||||||
|
B.y = newY;
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const shift = B.x >= A.x ? 1 : -1;
|
||||||
|
const newX = B.x + shift * (Math.min(w, h) / 2 + OVERLAP_SEP);
|
||||||
|
if (newX !== B.x) {
|
||||||
|
positions.set(B.id, { x: newX, y: B.y });
|
||||||
|
B.x = newX;
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!moved) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same-depth nodes in one row, non-overlapping horizontally (strict grid). */
|
||||||
|
function layoutAlignedRows(
|
||||||
|
positions: Map<string, { x: number; y: number }>,
|
||||||
|
depthById: Map<string, number>
|
||||||
|
): void {
|
||||||
|
const rowH = NODE_H + V_GAP;
|
||||||
|
const byDepth = new Map<number, string[]>();
|
||||||
|
for (const [id, d] of depthById) {
|
||||||
|
if (!positions.has(id)) continue;
|
||||||
|
if (!byDepth.has(d)) byDepth.set(d, []);
|
||||||
|
byDepth.get(d)!.push(id);
|
||||||
|
}
|
||||||
|
const depths = [...byDepth.keys()].sort((a, b) => a - b);
|
||||||
|
for (const d of depths) {
|
||||||
|
const rowIds = byDepth.get(d)!;
|
||||||
|
rowIds.sort((a, b) => (positions.get(a)!.x - positions.get(b)!.x));
|
||||||
|
const slot = NODE_LAYOUT_W + H_GAP;
|
||||||
|
const rowW = rowIds.length * slot - H_GAP;
|
||||||
|
let x = -rowW / 2;
|
||||||
|
const y = d * rowH;
|
||||||
|
for (const id of rowIds) {
|
||||||
|
positions.set(id, { x, y });
|
||||||
|
x += slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildGraph(
|
function buildGraph(
|
||||||
devices: TopologyDevice[],
|
devices: TopologyDevice[],
|
||||||
externalNodes: ExternalTopologyNode[],
|
externalNodes: ExternalTopologyNode[],
|
||||||
links: TopologyLink[]
|
links: TopologyLink[],
|
||||||
|
layout: TopologyLayoutOptions
|
||||||
): { nodes: Node[]; edges: Edge[] } {
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
|
||||||
// ── Shared-segment detection ────────────────────────────────────────────────
|
// ── Shared-segment detection ────────────────────────────────────────────────
|
||||||
@@ -204,11 +305,6 @@ function buildGraph(
|
|||||||
segGroups.get(root)!.push(pk);
|
segGroups.get(root)!.push(pk);
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDs of all links absorbed into segments (not rendered as direct edges)
|
|
||||||
const absorbedIds = new Set<number>(
|
|
||||||
sharedPortKeys.flatMap((pk) => portGroupMap.get(pk)!.map((l) => l.id))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build synthetic segment nodes and their connections
|
// Build synthetic segment nodes and their connections
|
||||||
interface SegConn { src: string; dst: string; port: string; }
|
interface SegConn { src: string; dst: string; port: string; }
|
||||||
const segNodes: ExternalTopologyNode[] = [];
|
const segNodes: ExternalTopologyNode[] = [];
|
||||||
@@ -292,67 +388,198 @@ function buildGraph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Root / BFS levels ───────────────────────────────────────────────────────
|
// ── Root selection ──────────────────────────────────────────────────────────
|
||||||
const hasStp = links.some((l) => l.stp_role);
|
const hasStp = links.some((l) => l.stp_role);
|
||||||
let rootId: string;
|
const deviceIds = new Set(devices.map((d) => String(d.id)));
|
||||||
if (hasStp) {
|
|
||||||
const devicesWithRootPort = new Set(
|
// Pick the best root per connected component so forests lay out cleanly.
|
||||||
links.filter((l) => l.stp_role === 'root').map((l) => String(l.from_device_id))
|
function pickRoot(componentIds: string[]): string {
|
||||||
);
|
const stpRoot = hasStp
|
||||||
const rootDevice = devices.find((d) => !devicesWithRootPort.has(String(d.id)));
|
? (() => {
|
||||||
rootId = rootDevice ? String(rootDevice.id) : devices[0] ? String(devices[0].id) : '';
|
const haveRootPort = new Set(
|
||||||
} else {
|
links.filter((l) => l.stp_role === 'root').map((l) => String(l.from_device_id))
|
||||||
rootId = devices.reduce((best, d) =>
|
);
|
||||||
(adj.get(String(d.id))?.size || 0) > (adj.get(best)?.size || 0) ? String(d.id) : best,
|
return componentIds.find((id) => deviceIds.has(id) && !haveRootPort.has(id));
|
||||||
devices[0] ? String(devices[0].id) : ''
|
})()
|
||||||
);
|
: undefined;
|
||||||
|
if (stpRoot) return stpRoot;
|
||||||
|
|
||||||
|
const inThisComponent = new Set(componentIds);
|
||||||
|
// Prefer managed devices with the most in-component neighbors.
|
||||||
|
let best = componentIds[0];
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const id of componentIds) {
|
||||||
|
const neighborCount = [...(adj.get(id) || [])].filter((n) => inThisComponent.has(n)).length;
|
||||||
|
const score = (deviceIds.has(id) ? 1000 : 0) + neighborCount;
|
||||||
|
if (score > bestScore) {
|
||||||
|
best = id;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
const levels = new Map<string, number>();
|
// ── Connected components ────────────────────────────────────────────────────
|
||||||
if (rootId) {
|
const allIds = [...devices.map((d) => String(d.id)), ...allExtNodes.map((e) => e.id)];
|
||||||
const queue: string[] = [rootId];
|
const visited = new Set<string>();
|
||||||
levels.set(rootId, 0);
|
const components: string[][] = [];
|
||||||
while (queue.length) {
|
for (const id of allIds) {
|
||||||
const curr = queue.shift()!;
|
if (visited.has(id)) continue;
|
||||||
for (const neighbor of adj.get(curr) || []) {
|
const stack = [id];
|
||||||
if (!levels.has(neighbor)) {
|
const comp: string[] = [];
|
||||||
levels.set(neighbor, levels.get(curr)! + 1);
|
while (stack.length) {
|
||||||
queue.push(neighbor);
|
const n = stack.pop()!;
|
||||||
}
|
if (visited.has(n)) continue;
|
||||||
|
visited.add(n);
|
||||||
|
comp.push(n);
|
||||||
|
for (const m of adj.get(n) || []) if (!visited.has(m)) stack.push(m);
|
||||||
|
}
|
||||||
|
components.push(comp);
|
||||||
|
}
|
||||||
|
// Biggest components first — nicer visual priority.
|
||||||
|
components.sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
|
// ── Tidy-tree layout per component (Reingold-Tilford style) ─────────────────
|
||||||
|
// For each component we build a spanning tree via BFS from its root, then
|
||||||
|
// assign x positions bottom-up using subtree widths. Siblings are ordered to
|
||||||
|
// minimize edge crossings by placing each child near the barycenter of its
|
||||||
|
// own children (a simple but effective heuristic for mostly-tree graphs).
|
||||||
|
|
||||||
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
|
const depthById = new Map<string, number>();
|
||||||
|
|
||||||
|
let componentOffsetX = 0;
|
||||||
|
let globalMaxDepth = 0;
|
||||||
|
|
||||||
|
for (const comp of components) {
|
||||||
|
if (!comp.length) continue;
|
||||||
|
const compSet = new Set(comp);
|
||||||
|
const rootId = pickRoot(comp);
|
||||||
|
|
||||||
|
// BFS to build a spanning tree (depth + children) for this component.
|
||||||
|
const depth = new Map<string, number>();
|
||||||
|
const children = new Map<string, string[]>();
|
||||||
|
const bfs = [rootId];
|
||||||
|
depth.set(rootId, 0);
|
||||||
|
children.set(rootId, []);
|
||||||
|
while (bfs.length) {
|
||||||
|
const curr = bfs.shift()!;
|
||||||
|
for (const n of adj.get(curr) || []) {
|
||||||
|
if (!compSet.has(n) || depth.has(n)) continue;
|
||||||
|
depth.set(n, depth.get(curr)! + 1);
|
||||||
|
children.set(n, []);
|
||||||
|
children.get(curr)!.push(n);
|
||||||
|
bfs.push(n);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Order children: devices first, then by in-component degree desc, then by
|
||||||
|
// id for stability. This keeps higher-fanout subtrees centered.
|
||||||
|
for (const kids of children.values()) {
|
||||||
|
kids.sort((a, b) => {
|
||||||
|
const da = deviceIds.has(a) ? 0 : 1;
|
||||||
|
const db = deviceIds.has(b) ? 0 : 1;
|
||||||
|
if (da !== db) return da - db;
|
||||||
|
const ga = (adj.get(a)?.size || 0);
|
||||||
|
const gb = (adj.get(b)?.size || 0);
|
||||||
|
if (gb !== ga) return gb - ga;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-order: compute subtree "slot width" then assign x.
|
||||||
|
const slotWidth = new Map<string, number>();
|
||||||
|
function computeWidth(id: string): number {
|
||||||
|
const kids = children.get(id) || [];
|
||||||
|
if (!kids.length) {
|
||||||
|
slotWidth.set(id, 1);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
let total = 0;
|
||||||
|
for (const k of kids) total += computeWidth(k);
|
||||||
|
slotWidth.set(id, Math.max(1, total));
|
||||||
|
return slotWidth.get(id)!;
|
||||||
|
}
|
||||||
|
computeWidth(rootId);
|
||||||
|
|
||||||
|
// Assign x: each node gets centered over the span of its children.
|
||||||
|
function assign(id: string, leftSlot: number): void {
|
||||||
|
const kids = children.get(id) || [];
|
||||||
|
if (!kids.length) {
|
||||||
|
positions.set(id, {
|
||||||
|
x: componentOffsetX + (leftSlot + 0.5) * NODE_SLOT - NODE_W / 2,
|
||||||
|
y: (depth.get(id) || 0) * (NODE_H + V_GAP),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cursor = leftSlot;
|
||||||
|
for (const k of kids) {
|
||||||
|
assign(k, cursor);
|
||||||
|
cursor += slotWidth.get(k)!;
|
||||||
|
}
|
||||||
|
// Center parent over its children's span.
|
||||||
|
const firstKid = positions.get(kids[0])!;
|
||||||
|
const lastKid = positions.get(kids[kids.length - 1])!;
|
||||||
|
positions.set(id, {
|
||||||
|
x: (firstKid.x + lastKid.x) / 2,
|
||||||
|
y: (depth.get(id) || 0) * (NODE_H + V_GAP),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assign(rootId, 0);
|
||||||
|
|
||||||
|
// Track overall depth and advance the X offset for the next component.
|
||||||
|
let compMaxDepth = 0;
|
||||||
|
for (const id of comp) {
|
||||||
|
const d = depth.get(id);
|
||||||
|
if (d !== undefined) depthById.set(id, d);
|
||||||
|
compMaxDepth = Math.max(compMaxDepth, depth.get(id) || 0);
|
||||||
|
}
|
||||||
|
globalMaxDepth = Math.max(globalMaxDepth, compMaxDepth);
|
||||||
|
|
||||||
|
const widthSlots = slotWidth.get(rootId) || 1;
|
||||||
|
componentOffsetX += widthSlots * NODE_SLOT + COMPONENT_GAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the whole layout horizontally around x=0 for a tidier fitView.
|
||||||
|
if (positions.size) {
|
||||||
|
const xs = [...positions.values()].map((p) => p.x);
|
||||||
|
const centerShift = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||||
|
for (const [id, p] of positions) {
|
||||||
|
positions.set(id, { x: p.x - centerShift, y: p.y });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLevel = levels.size > 0 ? Math.max(...levels.values()) : 0;
|
// Optional strict row alignment; otherwise organic tree + overlap separation.
|
||||||
const allIds = [...devices.map((d) => String(d.id)), ...allExtNodes.map((e) => e.id)];
|
if (layout.alignByDepth) {
|
||||||
for (const id of allIds) if (!levels.has(id)) levels.set(id, maxLevel + 1);
|
layoutAlignedRows(positions, depthById);
|
||||||
|
} else {
|
||||||
|
resolveOverlaps(positions);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Positioning ─────────────────────────────────────────────────────────────
|
// The STP root badge should only be shown for true STP roots, not just the
|
||||||
const byLevel = new Map<number, string[]>();
|
// picked tree root of each component.
|
||||||
for (const [id, lvl] of levels) {
|
const stpRootId = hasStp
|
||||||
if (!byLevel.has(lvl)) byLevel.set(lvl, []);
|
? (() => {
|
||||||
byLevel.get(lvl)!.push(id);
|
const haveRootPort = new Set(
|
||||||
}
|
links.filter((l) => l.stp_role === 'root').map((l) => String(l.from_device_id))
|
||||||
const positions = new Map<string, { x: number; y: number }>();
|
);
|
||||||
for (const [lvl, ids] of byLevel) {
|
return devices.map((d) => String(d.id)).find((id) => !haveRootPort.has(id)) ?? '';
|
||||||
const rowW = ids.length * NODE_W + (ids.length - 1) * H_GAP;
|
})()
|
||||||
ids.forEach((id, i) => {
|
: '';
|
||||||
positions.set(id, { x: -rowW / 2 + i * (NODE_W + H_GAP), y: lvl * (NODE_H + V_GAP) });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── React Flow nodes ─────────────────────────────────────────────────────────
|
// ── React Flow nodes ─────────────────────────────────────────────────────────
|
||||||
|
const orphanY = (globalMaxDepth + 1) * (NODE_H + V_GAP);
|
||||||
const nodes: Node[] = [
|
const nodes: Node[] = [
|
||||||
...devices.map((d) => ({
|
...devices.map((d) => ({
|
||||||
id: String(d.id),
|
id: String(d.id),
|
||||||
type: 'deviceNode',
|
type: 'deviceNode',
|
||||||
position: positions.get(String(d.id)) || { x: 0, y: 0 },
|
position: positions.get(String(d.id)) || { x: 0, y: 0 },
|
||||||
data: { ...d, isRootBridge: String(d.id) === rootId && hasStp } as unknown as Record<string, unknown>,
|
data: { ...d, isRootBridge: hasStp && String(d.id) === stpRootId } as unknown as Record<string, unknown>,
|
||||||
})),
|
})),
|
||||||
...allExtNodes.map((e) => ({
|
...allExtNodes.map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
type: 'externalNode',
|
type: 'externalNode',
|
||||||
position: positions.get(e.id) || { x: 0, y: (maxLevel + 1) * (NODE_H + V_GAP) },
|
position: positions.get(e.id) || { x: 0, y: orphanY },
|
||||||
data: e as unknown as Record<string, unknown>,
|
data: e as unknown as Record<string, unknown>,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
@@ -391,7 +618,8 @@ function buildGraph(
|
|||||||
source: src,
|
source: src,
|
||||||
target: dst,
|
target: dst,
|
||||||
label: (portLabel + stpLabel) || undefined,
|
label: (portLabel + stpLabel) || undefined,
|
||||||
labelStyle: { fontSize: 9, fill: '#94a3b8' },
|
labelStyle: { fontSize: 10, fill: 'var(--topology-edge-label, #64748b)' },
|
||||||
|
className: 'topology-edge',
|
||||||
style: { stroke, strokeWidth: 2, strokeDasharray },
|
style: { stroke, strokeWidth: 2, strokeDasharray },
|
||||||
animated,
|
animated,
|
||||||
});
|
});
|
||||||
@@ -407,7 +635,8 @@ function buildGraph(
|
|||||||
source: src,
|
source: src,
|
||||||
target: dst,
|
target: dst,
|
||||||
label: port || undefined,
|
label: port || undefined,
|
||||||
labelStyle: { fontSize: 9, fill: '#b45309' },
|
labelStyle: { fontSize: 10, fill: 'var(--topology-seg-edge-label, #b45309)' },
|
||||||
|
className: 'topology-edge topology-edge--segment',
|
||||||
style: { stroke: '#f59e0b', strokeWidth: 2, strokeDasharray: '5,3' },
|
style: { stroke: '#f59e0b', strokeWidth: 2, strokeDasharray: '5,3' },
|
||||||
animated: false,
|
animated: false,
|
||||||
});
|
});
|
||||||
@@ -417,6 +646,9 @@ function buildGraph(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TopologyPage() {
|
export default function TopologyPage() {
|
||||||
|
const theme = useThemeStore((s) => s.theme);
|
||||||
|
const [alignRows, setAlignRows] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery({
|
const { data, isLoading, refetch } = useQuery({
|
||||||
queryKey: ['topology'],
|
queryKey: ['topology'],
|
||||||
queryFn: () => topologyApi.get().then((r) => r.data),
|
queryFn: () => topologyApi.get().then((r) => r.data),
|
||||||
@@ -430,16 +662,20 @@ export default function TopologyPage() {
|
|||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
const graph = useMemo(() => {
|
||||||
if (!data) return;
|
if (!data) return { nodes: [] as Node[], edges: [] as Edge[] };
|
||||||
const { nodes: n, edges: e } = buildGraph(
|
return buildGraph(
|
||||||
(data.devices as TopologyDevice[]) || [],
|
(data.devices as TopologyDevice[]) || [],
|
||||||
(data.externalNodes as ExternalTopologyNode[]) || [],
|
(data.externalNodes as ExternalTopologyNode[]) || [],
|
||||||
(data.links as TopologyLink[]) || []
|
(data.links as TopologyLink[]) || [],
|
||||||
|
{ alignByDepth: alignRows }
|
||||||
);
|
);
|
||||||
setNodes(n);
|
}, [data, alignRows]);
|
||||||
setEdges(e);
|
|
||||||
}, [data, setNodes, setEdges]);
|
useEffect(() => {
|
||||||
|
setNodes(graph.nodes);
|
||||||
|
setEdges(graph.edges);
|
||||||
|
}, [graph, setNodes, setEdges]);
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => setEdges((eds) => addEdge(connection, eds)),
|
(connection: Connection) => setEdges((eds) => addEdge(connection, eds)),
|
||||||
@@ -459,7 +695,18 @@ export default function TopologyPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Network Topology</h1>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Network Topology</h1>
|
||||||
<div className="flex gap-2 flex-shrink-0">
|
<div className="flex flex-wrap items-center gap-3 flex-shrink-0">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-300 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-gray-300 dark:border-slate-600"
|
||||||
|
checked={alignRows}
|
||||||
|
onChange={(e) => setAlignRows(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span title="Place every node at the same tree depth on one horizontal row (strict grid). Off by default: organic tree layout with automatic spacing so labels and nodes do not overlap.">
|
||||||
|
Align by depth (rows)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
onClick={() => discoverMutation.mutate()}
|
onClick={() => discoverMutation.mutate()}
|
||||||
disabled={discoverMutation.isPending}
|
disabled={discoverMutation.isPending}
|
||||||
@@ -491,7 +738,7 @@ export default function TopologyPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="card" style={{ height: 'min(600px, 60vh)' }}>
|
<div className="card overflow-hidden" style={{ height: 'min(600px, 60vh)' }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -500,13 +747,17 @@ export default function TopologyPage() {
|
|||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2, includeHiddenNodes: false }}
|
||||||
minZoom={0.2}
|
minZoom={0.2}
|
||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
className="dark:bg-slate-800"
|
className={clsx(
|
||||||
|
'topology-reactflow h-full min-h-[400px] bg-slate-50 dark:bg-slate-800',
|
||||||
|
theme === 'dark' && 'dark'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Controls className="dark:bg-slate-700 dark:text-white" />
|
<Controls className="topology-controls !shadow-md" />
|
||||||
<MiniMap
|
<MiniMap
|
||||||
className="dark:bg-slate-700"
|
className="!shadow-md"
|
||||||
nodeColor={(n) => {
|
nodeColor={(n) => {
|
||||||
if (n.type === 'externalNode') return '#94a3b8';
|
if (n.type === 'externalNode') return '#94a3b8';
|
||||||
const d = n.data as unknown as TopologyDevice;
|
const d = n.data as unknown as TopologyDevice;
|
||||||
|
|||||||
@@ -59,16 +59,52 @@ export interface DiscoveredDevice {
|
|||||||
platform: string;
|
platform: string;
|
||||||
discovered_at: string;
|
discovered_at: string;
|
||||||
seen_by: string;
|
seen_by: string;
|
||||||
|
// When the neighbor matches a device that's already managed (by interface
|
||||||
|
// MAC, primary IP, or system identity) the backend fills these in so the
|
||||||
|
// UI can hide the row as a duplicate.
|
||||||
|
duplicate_of_device_id: number | null;
|
||||||
|
duplicate_of_device_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateSerialDeviceInfo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip_address: string;
|
||||||
|
serial_number: string;
|
||||||
|
model?: string;
|
||||||
|
ros_version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateSerialError {
|
||||||
|
error: string;
|
||||||
|
code: 'duplicate_serial';
|
||||||
|
existing_device: DuplicateSerialDeviceInfo;
|
||||||
|
candidate: {
|
||||||
|
serial_number: string;
|
||||||
|
identity: string;
|
||||||
|
model?: string;
|
||||||
|
ros_version?: string;
|
||||||
|
ip_address: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const devicesApi = {
|
export const devicesApi = {
|
||||||
list: () => api.get<Device[]>('/devices'),
|
list: () => api.get<Device[]>('/devices'),
|
||||||
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
||||||
get: (id: number) => api.get<Device>(`/devices/${id}`),
|
get: (id: number) => api.get<Device>(`/devices/${id}`),
|
||||||
create: (data: Partial<Device> & { api_password: string }) =>
|
create: (
|
||||||
api.post<Device>('/devices', data),
|
data: (Partial<Device> & {
|
||||||
update: (id: number, data: Partial<Device> & { api_password?: string }) =>
|
api_password?: string;
|
||||||
api.put<Device>(`/devices/${id}`, data),
|
ssh_password?: string;
|
||||||
|
credential_preset_id?: number;
|
||||||
|
force_replace_existing_by_serial?: boolean;
|
||||||
|
combine_with_device_id?: number;
|
||||||
|
})
|
||||||
|
) => api.post<Device>('/devices', data),
|
||||||
|
update: (
|
||||||
|
id: number,
|
||||||
|
data: Partial<Device> & { api_password?: string; ssh_password?: string; credential_preset_id?: number | null }
|
||||||
|
) => api.put<Device>(`/devices/${id}`, data),
|
||||||
delete: (id: number) => api.delete(`/devices/${id}`),
|
delete: (id: number) => api.delete(`/devices/${id}`),
|
||||||
sync: (id: number) => api.post(`/devices/${id}/sync`, undefined, { timeout: 60_000 }),
|
sync: (id: number) => api.post(`/devices/${id}/sync`, undefined, { timeout: 60_000 }),
|
||||||
test: (id: number) => api.post<{ success: boolean; identity?: string; error?: string }>(`/devices/${id}/test`),
|
test: (id: number) => api.post<{ success: boolean; identity?: string; error?: string }>(`/devices/${id}/test`),
|
||||||
@@ -152,6 +188,42 @@ export const devicesApi = {
|
|||||||
}) => api.patch<Device>(`/devices/${id}/location`, data),
|
}) => api.patch<Device>(`/devices/${id}/location`, data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Credential Presets ──────────────────────────────────────────────────────
|
||||||
|
export interface CredentialPreset {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
api_username: string;
|
||||||
|
api_port: number | null;
|
||||||
|
ssh_username: string | null;
|
||||||
|
ssh_port: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
has_api_password: boolean;
|
||||||
|
has_ssh_password: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialPresetInput {
|
||||||
|
name?: string;
|
||||||
|
api_username?: string;
|
||||||
|
api_password?: string;
|
||||||
|
api_port?: number | null;
|
||||||
|
ssh_username?: string | null;
|
||||||
|
ssh_password?: string | null;
|
||||||
|
ssh_port?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
clear_ssh_password?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const credentialPresetsApi = {
|
||||||
|
list: () => api.get<CredentialPreset[]>('/credential-presets'),
|
||||||
|
create: (data: CredentialPresetInput) =>
|
||||||
|
api.post<CredentialPreset>('/credential-presets', data),
|
||||||
|
update: (id: number, data: CredentialPresetInput) =>
|
||||||
|
api.put<CredentialPreset>(`/credential-presets/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/credential-presets/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Clients ──────────────────────────────────────────────────────────────────
|
// ─── Clients ──────────────────────────────────────────────────────────────────
|
||||||
export interface ClientDetail extends Client {
|
export interface ClientDetail extends Client {
|
||||||
device_type?: string;
|
device_type?: string;
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ export interface TopologyDevice {
|
|||||||
device_type: DeviceType;
|
device_type: DeviceType;
|
||||||
status: DeviceStatus;
|
status: DeviceStatus;
|
||||||
ros_version?: string;
|
ros_version?: string;
|
||||||
|
/** Cached /ip/address rows from the device (set by backend sync). */
|
||||||
|
ip_addresses_jsonb?: { address: string; interface?: string; dynamic?: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopologyLink {
|
export interface TopologyLink {
|
||||||
|
|||||||
Reference in New Issue
Block a user