import { pool } from '../../config/db.js'; const DEVICE_COLS = [ 'hostname', 'kind', 'manufacturer', 'model', 'serial', 'os_name', 'os_version', 'os_arch', 'domain', 'last_user', 'ip_main', 'mac_main', 'cpu_summary', 'ram_mb', 'disk_total_mb', 'disk_free_mb', 'max_disk_used_pct', 'agent_version', 'tag', 'itemtype', ]; const CHILD_TABLES = { cpus: { table: 'device_cpus', cols: ['name', 'manufacturer', 'cores', 'threads', 'speed_mhz'] }, memories: { table: 'device_memories', cols: ['slot', 'capacity_mb', 'type', 'speed', 'manufacturer', 'serial'] }, disks: { table: 'device_disks', cols: ['name', 'model', 'manufacturer', 'type', 'interface', 'size_mb', 'serial'] }, volumes: { table: 'device_volumes', cols: ['mount', 'label', 'filesystem', 'type', 'total_mb', 'free_mb'] }, networks: { table: 'device_networks', cols: ['description', 'mac', 'ip', 'mask', 'gateway', 'type', 'status'] }, monitors: { table: 'device_monitors', cols: ['manufacturer', 'model', 'serial'] }, accounts: { table: 'device_accounts', cols: ['kind', 'name', 'domain', 'sid'] }, software: { table: 'device_software', cols: ['name', 'version', 'publisher', 'install_date', 'category', 'is_component'] }, }; // ---------- Tokens de ingesta ---------- export async function findActiveTokenByHash(hash) { const [rows] = await pool.query('SELECT id FROM inventory_tokens WHERE token_hash = ? AND is_active = 1', [hash]); return rows[0] || null; } export const touchToken = (id) => pool.query('UPDATE inventory_tokens SET last_used_at = NOW() WHERE id = ?', [id]); export async function listTokens() { const [rows] = await pool.query( `SELECT t.id, t.label, t.is_active, t.last_used_at, t.created_at, u.name AS created_by_name, (t.token_enc IS NOT NULL) AS has_secret FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC` ); return rows.map((r) => ({ ...r, has_secret: !!r.has_secret })); } export async function createToken(label, hash, enc, userId) { const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, token_enc, created_by) VALUES (?, ?, ?, ?)', [label, hash, enc, userId]); return r.insertId; } export async function findTokenSecret(id) { const [[row]] = await pool.query('SELECT token_enc FROM inventory_tokens WHERE id = ?', [id]); return row?.token_enc || null; } export const setTokenActive = (id, active) => pool.query('UPDATE inventory_tokens SET is_active = ? WHERE id = ?', [active ? 1 : 0, id]); export const deleteToken = (id) => pool.query('DELETE FROM inventory_tokens WHERE id = ?', [id]); // ---------- Acceso compartido (solo lectura) ---------- export async function isViewer(userId) { const [rows] = await pool.query('SELECT 1 FROM device_viewers WHERE user_id = ?', [userId]); return rows.length > 0; } export async function listViewers() { const [rows] = await pool.query( `SELECT v.user_id, u.name, u.email, r.name AS role_name, v.created_at, g.name AS granted_by_name FROM device_viewers v JOIN users u ON u.id = v.user_id JOIN roles r ON r.id = u.role_id LEFT JOIN users g ON g.id = v.granted_by ORDER BY u.name` ); return rows; } export const addViewer = (userId, grantedBy) => pool.query('INSERT IGNORE INTO device_viewers (user_id, granted_by) VALUES (?, ?)', [userId, grantedBy]); export const removeViewer = (userId) => pool.query('DELETE FROM device_viewers WHERE user_id = ?', [userId]); export async function findUserBrief(userId) { const [[row]] = await pool.query('SELECT u.id, u.name, u.is_active, r.code AS role_code FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?', [userId]); return row || null; } // ---------- Ingesta ---------- // Foto de lo que hay hoy en BD (para calcular el diff contra el reporte nuevo). async function snapshotOf(conn, deviceId) { const [[dev]] = await conn.query('SELECT hostname, os_name, os_version, ram_mb FROM devices WHERE id = ?', [deviceId]); const [disks] = await conn.query('SELECT model, serial, size_mb FROM device_disks WHERE device_id = ?', [deviceId]); const [software] = await conn.query('SELECT name, version FROM device_software WHERE device_id = ?', [deviceId]); return { dev, disks, software }; } function diffSnapshots(before, after) { const changes = []; for (const f of ['hostname', 'os_name', 'os_version', 'ram_mb']) { if ((before.dev[f] ?? null) !== (after.dev[f] ?? null)) { changes.push({ change_type: 'field', field: f, old_value: before.dev[f], new_value: after.dev[f] }); } } const diskKey = (d) => `${d.model || ''}|${d.serial || ''}|${d.size_mb || ''}`; const swKey = (s) => `${s.name}|${s.version || ''}`; const compare = (a, b, keyFn, labelFn, addType, removeType) => { const ka = new Set(a.map(keyFn)); const kb = new Set(b.map(keyFn)); for (const x of b) if (!ka.has(keyFn(x))) changes.push({ change_type: addType, field: null, old_value: null, new_value: labelFn(x) }); for (const x of a) if (!kb.has(keyFn(x))) changes.push({ change_type: removeType, field: null, old_value: labelFn(x), new_value: null }); }; compare(before.disks, after.disks, diskKey, (d) => `${d.model || 'Disco'} ${d.size_mb ? Math.round(d.size_mb / 1024) + ' GB' : ''}`.trim(), 'disk_added', 'disk_removed'); compare(before.software, after.software, swKey, (s) => `${s.name}${s.version ? ` ${s.version}` : ''}`, 'software_installed', 'software_removed'); return changes; } // Upsert por deviceid. Reemplaza todos los componentes dentro de una transaccion y registra el diff. // Un equipo dado de baja (status=retired) sigue actualizando datos pero NO se reactiva solo. export async function upsertInventory(deviceid, n, rawFile) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [[existing]] = await conn.query('SELECT id FROM devices WHERE deviceid = ? FOR UPDATE', [deviceid]); const d = n.device; const values = DEVICE_COLS.map((c) => d[c] ?? null); const extraJson = Object.keys(n.extra).length ? JSON.stringify(n.extra) : null; let deviceId; let before = null; if (existing) { deviceId = existing.id; before = await snapshotOf(conn, deviceId); await conn.query( `UPDATE devices SET ${DEVICE_COLS.map((c) => `${c} = ?`).join(', ')}, last_seen = NOW(), raw_file = ?, extra_json = ? WHERE id = ?`, [...values, rawFile, extraJson, deviceId] ); for (const { table } of Object.values(CHILD_TABLES)) await conn.query(`DELETE FROM ${table} WHERE device_id = ?`, [deviceId]); } else { const [r] = await conn.query( `INSERT INTO devices (deviceid, ${DEVICE_COLS.join(', ')}, raw_file, extra_json) VALUES (?, ${DEVICE_COLS.map(() => '?').join(', ')}, ?, ?)`, [deviceid, ...values, rawFile, extraJson] ); deviceId = r.insertId; } for (const [key, { table, cols }] of Object.entries(CHILD_TABLES)) { const rows = n[key]; if (!rows.length) continue; await conn.query( `INSERT INTO ${table} (device_id, ${cols.join(', ')}) VALUES ?`, [rows.map((row) => [deviceId, ...cols.map((c) => row[c] ?? null)])] ); } let changeCount = 0; if (before) { const after = { dev: d, disks: n.disks, software: n.software, }; const changes = diffSnapshots(before, after); changeCount = changes.length; if (changes.length) { await conn.query( 'INSERT INTO device_changes (device_id, change_type, field, old_value, new_value) VALUES ?', [changes.map((c) => [deviceId, c.change_type, c.field, c.old_value == null ? null : String(c.old_value), c.new_value == null ? null : String(c.new_value)])] ); } } await conn.commit(); return { id: deviceId, created: !existing, changeCount }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } // ---------- Consultas de la UI ---------- export async function findAll({ search, kind, status, staleDays, diskFull }) { const where = []; const params = []; if (status) { where.push('d.status = ?'); params.push(status); } if (kind) { where.push('d.kind = ?'); params.push(kind); } if (staleDays) { where.push('d.last_seen < NOW() - INTERVAL ? DAY'); params.push(staleDays); } if (diskFull) where.push('d.max_disk_used_pct >= 90'); if (search) { where.push(`(d.hostname LIKE ? OR d.serial LIKE ? OR d.model LIKE ? OR d.ip_main LIKE ? OR d.mac_main LIKE ? OR d.last_user LIKE ? OR u.name LIKE ?)`); params.push(...Array(7).fill(`%${search}%`)); } const [rows] = await pool.query( `SELECT d.id, d.hostname, d.kind, d.manufacturer, d.model, d.serial, d.os_name, d.os_version, d.ip_main, d.mac_main, d.last_user, d.ram_mb, d.disk_total_mb, d.max_disk_used_pct, d.first_seen, d.last_seen, d.status, d.assigned_user_id, u.name AS assigned_user_name, d.department_id, dep.name AS department_name FROM devices d LEFT JOIN users u ON u.id = d.assigned_user_id LEFT JOIN departments dep ON dep.id = d.department_id ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY d.hostname`, params ); return rows; } export async function findById(id) { const [[device]] = await pool.query( `SELECT d.*, u.name AS assigned_user_name, dep.name AS department_name FROM devices d LEFT JOIN users u ON u.id = d.assigned_user_id LEFT JOIN departments dep ON dep.id = d.department_id WHERE d.id = ?`, [id] ); if (!device) return null; const detail = { ...device, extra: device.extra_json ? JSON.parse(device.extra_json) : null }; delete detail.extra_json; for (const [key, { table }] of Object.entries(CHILD_TABLES)) { const order = key === 'software' ? 'name' : 'id'; const [rows] = await pool.query(`SELECT * FROM ${table} WHERE device_id = ? ORDER BY ${order}`, [id]); detail[key] = rows; } const [changes] = await pool.query( 'SELECT id, change_type, field, old_value, new_value, created_at FROM device_changes WHERE device_id = ? ORDER BY created_at DESC, id DESC LIMIT 200', [id] ); detail.changes = changes; return detail; } export async function updateManual(id, { status, assignedUserId, departmentId, notes, editVersion }) { const [result] = await pool.query( 'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [status, assignedUserId, departmentId, notes, id, editVersion] ); return result.affectedRows > 0; } export async function findRawFile(id) { const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]); return row?.raw_file || null; } // Por defecto solo aplicaciones; `all` incluye actualizaciones/runtimes/drivers (is_component). export async function listSoftware({ search, all }) { const params = []; const clauses = []; if (!all) clauses.push('s.is_component = 0'); if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); } const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const [rows] = await pool.query( `SELECT s.name, MAX(s.publisher) AS publisher, COUNT(DISTINCT s.device_id) AS device_count, COUNT(DISTINCT s.version) AS version_count FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active' ${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`, params ); return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) })); } export async function listDevicesWithSoftware(name) { const [rows] = await pool.query( `SELECT d.id, d.hostname, d.kind, d.last_user, s.version FROM device_software s JOIN devices d ON d.id = s.device_id WHERE s.name = ? AND d.status = 'active' ORDER BY d.hostname`, [name] ); return rows; } export async function dashboard(staleDays) { const one = async (sql, params = []) => (await pool.query(sql, params))[0]; const [[totals]] = await pool.query( `SELECT COUNT(*) AS total, SUM(last_seen < NOW() - INTERVAL ? DAY) AS stale, SUM(max_disk_used_pct >= 90) AS disk_full, SUM(ram_mb IS NOT NULL AND ram_mb < 4096) AS low_ram FROM devices WHERE status = 'active'`, [staleDays] ); const byKind = await one(`SELECT kind AS label, COUNT(*) AS value FROM devices WHERE status = 'active' GROUP BY kind ORDER BY value DESC`); const byOs = await one(`SELECT COALESCE(os_name, '—') AS label, COUNT(*) AS value FROM devices WHERE status = 'active' GROUP BY os_name ORDER BY value DESC LIMIT 8`); const topSoftware = await one( `SELECT s.name AS label, COUNT(DISTINCT s.device_id) AS value FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active' WHERE s.is_component = 0 GROUP BY s.name ORDER BY value DESC LIMIT 8` ); const n = (v) => Number(v || 0); return { total: n(totals.total), stale: n(totals.stale), diskFull: n(totals.disk_full), lowRam: n(totals.low_ram), byKind: byKind.map((r) => ({ ...r, value: n(r.value) })), byOs: byOs.map((r) => ({ ...r, value: n(r.value) })), topSoftware: topSoftware.map((r) => ({ ...r, value: n(r.value) })), }; }