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:
@@ -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 { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
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();
|
||||
router.use(requireAuth);
|
||||
@@ -71,6 +100,32 @@ router.get('/discovered', async (_req: Request, res: Response) => {
|
||||
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
|
||||
const seen = new Map<string, typeof rows[0] & { seen_by_list: string[] }>();
|
||||
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) => ({
|
||||
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(', '),
|
||||
}));
|
||||
const results = Array.from(seen.values()).map((r) => {
|
||||
const mac = (r.neighbor_mac || '').toLowerCase();
|
||||
const match =
|
||||
(mac && macToDevice.get(mac)) ||
|
||||
(r.neighbor_address && ipToDevice.get(r.neighbor_address)) ||
|
||||
(r.neighbor_identity && nameToDevice.get(r.neighbor_identity.toLowerCase())) ||
|
||||
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);
|
||||
});
|
||||
|
||||
// POST /api/devices
|
||||
router.post('/', requireWrite, async (req: Request, res: Response) => {
|
||||
const { name, ip_address, api_port = 8728, api_username, api_password,
|
||||
ssh_port = 22, ssh_username, ssh_password, device_type = 'router', notes } = req.body;
|
||||
const {
|
||||
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) {
|
||||
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);
|
||||
let detectedSerial: string | null = null;
|
||||
try {
|
||||
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) {
|
||||
return res.status(422).json({
|
||||
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);
|
||||
@@ -204,28 +384,80 @@ router.patch('/:id/location', requireWrite, async (req: Request, res: Response)
|
||||
|
||||
// PUT /api/devices/:id
|
||||
router.put('/:id', requireWrite, async (req: Request, res: Response) => {
|
||||
const { name, api_port, api_username, api_password, ssh_port, ssh_username,
|
||||
ssh_password, device_type, notes } = req.body;
|
||||
const { name, ip_address, device_type, notes, credential_preset_id } = req.body as {
|
||||
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 }>(
|
||||
`SELECT id, api_password_encrypted FROM devices WHERE id = $1`,
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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]
|
||||
);
|
||||
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 encSshPass = ssh_password ? encrypt(ssh_password) : null;
|
||||
|
||||
await query(
|
||||
`UPDATE devices SET
|
||||
name=COALESCE($1,name), api_port=COALESCE($2,api_port),
|
||||
api_username=COALESCE($3,api_username), api_password_encrypted=$4,
|
||||
ssh_port=COALESCE($5,ssh_port), ssh_username=COALESCE($6,ssh_username),
|
||||
ssh_password_encrypted=COALESCE($7,ssh_password_encrypted),
|
||||
device_type=COALESCE($8,device_type), notes=COALESCE($9,notes),
|
||||
name=COALESCE($1,name), ip_address=COALESCE($2,ip_address),
|
||||
api_port=COALESCE($3,api_port),
|
||||
api_username=COALESCE($4,api_username), api_password_encrypted=$5,
|
||||
ssh_port=COALESCE($6,ssh_port), 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 = $10`,
|
||||
[name, api_port, api_username, encPass, ssh_port, ssh_username, encSshPass, device_type, notes, req.params.id]
|
||||
WHERE id = $11`,
|
||||
[name, ip_address, api_port, api_username, encPass, ssh_port, ssh_username, encSshPass, device_type, notes, req.params.id]
|
||||
);
|
||||
|
||||
const updated = await queryOne(
|
||||
@@ -732,7 +964,7 @@ router.post('/:id/check-update', async (req: Request, res: Response) => {
|
||||
const statusText = (updateInfo['status'] ?? '').toLowerCase();
|
||||
const hasUpdate =
|
||||
statusText.includes('available') ||
|
||||
(latestVersion && installedVersion && latestVersion !== installedVersion);
|
||||
Boolean(latestVersion && installedVersion && latestVersion !== installedVersion);
|
||||
|
||||
await query(
|
||||
`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([
|
||||
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`
|
||||
),
|
||||
query<LinkRow>(
|
||||
@@ -70,6 +71,51 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
}
|
||||
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
|
||||
const externalMap = new Map<string, {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user