feat: version 2.15.0 -- menu Equipos (inventario de TI con agente GLPI)
Ingesta POST /api/inventory (JSON/XML, token de instalacion), upsert por deviceid, historial de cambios, pestañas Resumen/Equipos/Software/Agentes, detalle con buscador por pestaña y casilla para ocultar actualizaciones/drivers/componentes del software. Migraciones 023 y 024. Diseño en docs/ARQUITECTURA-INVENTARIO.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ import updaterRoutes from './modules/updater/routes.js';
|
||||
import errorLogsRoutes from './modules/errorLogs/routes.js';
|
||||
import notesRoutes from './modules/notes/routes.js';
|
||||
import vaultRoutes from './modules/vault/routes.js';
|
||||
import devicesRoutes from './modules/devices/routes.js';
|
||||
import inventoryIngestRoutes from './modules/devices/ingest.js';
|
||||
|
||||
export const app = express();
|
||||
|
||||
@@ -34,6 +36,8 @@ app.set('trust proxy', 1);
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.use(cors({ origin: env.corsOrigin, credentials: true }));
|
||||
// Ingesta del agente GLPI: ANTES del parser JSON (usa cuerpo crudo, sin CORS: la llama el agente).
|
||||
app.use(inventoryIngestRoutes);
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null }));
|
||||
@@ -57,6 +61,7 @@ app.use('/api/updater', updaterRoutes);
|
||||
app.use('/api/error-logs', errorLogsRoutes);
|
||||
app.use('/api/notes', notesRoutes);
|
||||
app.use('/api/vault', vaultRoutes);
|
||||
app.use('/api/devices', devicesRoutes);
|
||||
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
||||
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getDevices = asyncHandler(async (req, res) => ok(res, await service.listDevices(req.query)));
|
||||
export const getDevice = asyncHandler(async (req, res) => ok(res, await service.getDevice(Number(req.params.id))));
|
||||
export const putDevice = asyncHandler(async (req, res) => ok(res, await service.updateDevice(Number(req.params.id), req.body, req.currentUser)));
|
||||
export const getDashboard = asyncHandler(async (req, res) => ok(res, await service.getDashboard()));
|
||||
export const getSoftware = asyncHandler(async (req, res) => ok(res, await service.listSoftware(req.query.q, req.query.all)));
|
||||
export const getSoftwareDevices = asyncHandler(async (req, res) => ok(res, await service.devicesWithSoftware(String(req.query.name || ''))));
|
||||
|
||||
export const getRaw = asyncHandler(async (req, res) => {
|
||||
const { full, file } = await service.getRaw(Number(req.params.id));
|
||||
res.download(full, file);
|
||||
});
|
||||
|
||||
export const getTokens = asyncHandler(async (req, res) => ok(res, await service.listTokens()));
|
||||
export const postToken = asyncHandler(async (req, res) => ok(res, await service.createToken(req.body.label, req.currentUser), 201));
|
||||
export const patchToken = asyncHandler(async (req, res) => ok(res, await service.setTokenActive(Number(req.params.id), !!req.body.isActive, req.currentUser)));
|
||||
export const deleteToken = asyncHandler(async (req, res) => ok(res, await service.removeToken(Number(req.params.id), req.currentUser)));
|
||||
@@ -0,0 +1,124 @@
|
||||
import crypto from 'node:crypto';
|
||||
import zlib from 'node:zlib';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import { env } from '../../config/env.js';
|
||||
import { HttpError, asyncHandler } from '../../utils/http.js';
|
||||
import * as repo from './repository.js';
|
||||
import { parseInventoryText, normalizeInventory } from './normalizer.js';
|
||||
|
||||
// Ingesta del agente GLPI. NO usa JWT de usuario: cada instalacion lleva un token (guardado como
|
||||
// hash en inventory_tokens) que el agente manda como password Basic, Bearer, X-Ingest-Token o
|
||||
// ?token=. Va montada ANTES de express.json (cuerpo crudo) en app.js.
|
||||
|
||||
const MAX_BYTES = Number(process.env.INVENTORY_MAX_BYTES || 15 * 1024 * 1024);
|
||||
const MAX_INFLATED = MAX_BYTES * 4; // tope de inflado (anti zip-bomb)
|
||||
const REPORT_EVERY_HOURS = 24;
|
||||
|
||||
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
|
||||
|
||||
function extractToken(req) {
|
||||
if (req.headers['x-ingest-token']) return String(req.headers['x-ingest-token']).trim();
|
||||
const auth = req.headers.authorization || '';
|
||||
if (auth.startsWith('Bearer ')) return auth.slice(7).trim();
|
||||
if (auth.startsWith('Basic ')) {
|
||||
try {
|
||||
const dec = Buffer.from(auth.slice(6), 'base64').toString('utf8');
|
||||
return dec.includes(':') ? dec.slice(dec.indexOf(':') + 1) : dec;
|
||||
} catch { /* token invalido: cae abajo */ }
|
||||
}
|
||||
if (req.query?.token) return String(req.query.token).trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
function inflate(buf) {
|
||||
const opts = { maxOutputLength: MAX_INFLATED };
|
||||
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return zlib.gunzipSync(buf, opts);
|
||||
if (buf.length > 2 && buf[0] === 0x78) return zlib.inflateSync(buf, opts);
|
||||
return buf;
|
||||
}
|
||||
|
||||
function detectFormat(text) {
|
||||
const t = text.trimStart();
|
||||
if (t.startsWith('{') || t.startsWith('[')) return 'json';
|
||||
if (t.startsWith('<')) return 'xml';
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveRaw(deviceid, text, format) {
|
||||
const dir = path.join(env.storageRoot, 'inventories');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Un archivo por equipo (se sobrescribe): solo interesa el ultimo reporte crudo para depurar.
|
||||
const name = `${sha256(deviceid).slice(0, 24)}.${format}`;
|
||||
fs.writeFileSync(path.join(dir, name), text);
|
||||
return name;
|
||||
}
|
||||
|
||||
const ok = (res, format) => (format === 'xml'
|
||||
? res.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><REPLY></REPLY>')
|
||||
: res.json({ status: 'ok' }));
|
||||
|
||||
const receive = asyncHandler(async (req, res) => {
|
||||
const token = extractToken(req);
|
||||
const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null;
|
||||
// Log de diagnostico de TODA peticion del agente (sin imprimir el token): util para depurar la conexion.
|
||||
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`);
|
||||
if (!tokenRow) {
|
||||
// El agente (LWP) manda las credenciales solo despues de recibir este desafio Basic.
|
||||
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
|
||||
throw new HttpError(401, 'No autorizado');
|
||||
}
|
||||
repo.touchToken(tokenRow.id).catch(() => {});
|
||||
|
||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
||||
if (!body.length) throw new HttpError(400, 'Cuerpo vacio');
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = inflate(body).toString('utf8');
|
||||
} catch {
|
||||
throw new HttpError(400, 'Cuerpo comprimido invalido o demasiado grande');
|
||||
}
|
||||
const format = detectFormat(text);
|
||||
if (!format) throw new HttpError(400, 'Formato no reconocido');
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseInventoryText(text, format);
|
||||
} catch {
|
||||
throw new HttpError(400, 'No se pudo interpretar el inventario');
|
||||
}
|
||||
|
||||
// Handshake del agente antes de enviar el inventario: se contesta sin guardar nada.
|
||||
if (parsed.action === 'contact' || parsed.action === 'prolog') {
|
||||
if (format === 'xml') {
|
||||
return res.type('application/xml').send(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><REPLY><RESPONSE>SEND</RESPONSE><PROLOG_FREQ>${REPORT_EVERY_HOURS}</PROLOG_FREQ></REPLY>`
|
||||
);
|
||||
}
|
||||
return res.json({ status: 'ok', expiration: String(REPORT_EVERY_HOURS) });
|
||||
}
|
||||
if (parsed.action && parsed.action !== 'inventory') return ok(res, format);
|
||||
|
||||
if (!parsed.deviceid) throw new HttpError(400, 'Inventario sin deviceid');
|
||||
const normalized = normalizeInventory(parsed);
|
||||
const rawFile = saveRaw(parsed.deviceid, text, format);
|
||||
const result = await repo.upsertInventory(parsed.deviceid, normalized, rawFile);
|
||||
console.log(`[inventario] ${result.created ? 'nuevo' : 'actualizado'} #${result.id} ${normalized.device.hostname || parsed.deviceid} (${format}, ${result.changeCount} cambios)`);
|
||||
return ok(res, format);
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
const rawBody = express.raw({ type: () => true, limit: MAX_BYTES });
|
||||
|
||||
// Ruta propia + alias de la ruta nativa de GLPI (para no parchear el agente).
|
||||
router.post('/api/inventory', rawBody, receive);
|
||||
router.post('/front/inventory.php', rawBody, receive);
|
||||
// Cualquier otro metodo/ruta que el agente pruebe bajo estas rutas queda registrado.
|
||||
router.all(['/api/inventory', '/front/inventory.php'], (req, res) => {
|
||||
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} (metodo no soportado)`);
|
||||
res.status(405).json({ success: false, data: null, error: 'Usa POST' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
// Convierte el inventario del agente GLPI (JSON o XML) a un objeto normalizado para el modelo
|
||||
// `devices*`. El JSON nativo y el XML comparten nombres de campo (el XML en MAYUSCULAS), asi que
|
||||
// el XML se pasa a minusculas y ambos formatos usan el mismo mapeo. Todo es tolerante: un campo
|
||||
// ausente es null, nunca una excepcion (el formato varia por version del agente y por SO).
|
||||
|
||||
const XML_ARRAY_SECTIONS = new Set([
|
||||
'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers',
|
||||
'users', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus',
|
||||
]);
|
||||
|
||||
const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, trimValues: true });
|
||||
|
||||
function lowerKeys(value) {
|
||||
if (Array.isArray(value)) return value.map(lowerKeys);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k.toLowerCase(), lowerKeys(v)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const asArray = (v) => (v == null || v === '' ? [] : Array.isArray(v) ? v : [v]);
|
||||
const str = (v, max = 255) => {
|
||||
if (v == null) return null;
|
||||
const s = String(typeof v === 'object' ? '' : v).trim();
|
||||
return s ? s.slice(0, max) : null;
|
||||
};
|
||||
const num = (v) => {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(String(v).replace(/[^\d.-]/g, ''));
|
||||
return Number.isFinite(n) ? Math.round(n) : null;
|
||||
};
|
||||
|
||||
// Devuelve { content, deviceid, itemtype, action, tag } o null si no se reconoce estructura.
|
||||
export function parseInventoryText(text, format) {
|
||||
if (format === 'json') {
|
||||
const obj = JSON.parse(text);
|
||||
return {
|
||||
action: obj.action || null,
|
||||
deviceid: str(obj.deviceid || obj.content?.deviceid),
|
||||
itemtype: str(obj.itemtype, 60),
|
||||
tag: str(obj.tag, 120),
|
||||
content: lowerKeys(obj.content || obj),
|
||||
};
|
||||
}
|
||||
const parsed = lowerKeys(xmlParser.parse(text));
|
||||
const req = parsed.request || parsed;
|
||||
const content = req.content || {};
|
||||
for (const key of XML_ARRAY_SECTIONS) {
|
||||
if (content[key] !== undefined) content[key] = asArray(content[key]);
|
||||
}
|
||||
return {
|
||||
action: str(req.query) === 'PROLOG' ? 'prolog' : (str(req.query) || '').toLowerCase() || null,
|
||||
deviceid: str(req.deviceid),
|
||||
itemtype: null,
|
||||
tag: str(content.accountinfo?.keyvalue, 120),
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
// Software que NO es una aplicacion "de usuario": actualizaciones, runtimes, drivers y paquetes del
|
||||
// sistema. Se decide por la categoria que reporta el agente (system_category) y, para lo que el
|
||||
// agente marca como "application"/"store" pero es ruido, por el nombre.
|
||||
const COMPONENT_CATEGORIES = new Set(['system', 'system_component', 'update', 'security_update', 'hotfix']);
|
||||
const COMPONENT_NAME = new RegExp([
|
||||
'^KB\\d{6,8}$', '\\(KB\\d{6,8}\\)', 'redistributable', 'runtime package', '\\.net native', '\\.net (host|runtime|sdk|targeting|apphost|toolset)',
|
||||
'windowsappruntime', 'ui xaml', 'advertising sdk', 'vclibs', 'driver', 'controlador', 'firmware', 'chipset',
|
||||
'management engine', 'rapid storage', 'serial io', 'hardware accelerated', 'compatibility fix', 'subsystem for linux',
|
||||
'webview2', 'update health tools', 'pc health', 'comprobaci[oó]n de estado', 'desinstalar impresora', 'printer uninstall',
|
||||
'^microsoftwindows\\.', '^[0-9a-f]{8}-[0-9a-f]{4}-', '^windows sdk', 'software development kit.*(windows|arm|x64|x86)',
|
||||
].join('|'), 'i');
|
||||
function isComponent(name, category) {
|
||||
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
|
||||
}
|
||||
|
||||
const LAPTOP_CHASSIS = /laptop|notebook|portable|hand held|sub notebook|convertible|tablet|detachable|netbook/i;
|
||||
const SERVER_CHASSIS = /server|rack|blade|main server|multi-system/i;
|
||||
|
||||
function detectKind(parsed, hw, os) {
|
||||
const type = (parsed.itemtype || '').toLowerCase();
|
||||
if (type === 'printer') return 'printer';
|
||||
if (type === 'networkequipment') return 'network';
|
||||
const chassis = `${hw.chassis_type || ''}`;
|
||||
if (hw.vmsystem && !/physical/i.test(hw.vmsystem)) return 'virtual';
|
||||
if (LAPTOP_CHASSIS.test(chassis)) return 'laptop';
|
||||
if (SERVER_CHASSIS.test(chassis) || /server/i.test(`${os.name || ''} ${os.full_name || ''} ${hw.osname || ''}`)) return 'server';
|
||||
if (chassis) return 'desktop';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function normalizeInventory(parsed) {
|
||||
const c = parsed.content || {};
|
||||
const hw = c.hardware || {};
|
||||
const bios = c.bios || {};
|
||||
const os = c.operatingsystem || {};
|
||||
const isAgentless = !!(c.network_device || c.printers?.length) && !c.hardware?.name;
|
||||
|
||||
// Equipos descubiertos por SNMP (netinventory): vienen como network_device o printers.
|
||||
const nd = c.network_device || {};
|
||||
const printer = asArray(c.printers)[0] || {};
|
||||
|
||||
const cpus = asArray(c.cpus).map((x) => ({
|
||||
name: str(x.name), manufacturer: str(x.manufacturer, 120),
|
||||
cores: num(x.core ?? x.cores), threads: num(x.thread ?? x.threads), speed_mhz: num(x.speed),
|
||||
}));
|
||||
|
||||
const memories = asArray(c.memories)
|
||||
.map((x) => ({
|
||||
slot: str(x.numslots ?? x.caption ?? x.description, 80), capacity_mb: num(x.capacity),
|
||||
type: str(x.type, 60), speed: str(x.speed, 40), manufacturer: str(x.manufacturer, 120), serial: str(x.serialnumber ?? x.serial, 120),
|
||||
}))
|
||||
.filter((m) => m.capacity_mb);
|
||||
|
||||
const disks = asArray(c.storages).map((x) => ({
|
||||
name: str(x.name, 190), model: str(x.model ?? x.description, 190), manufacturer: str(x.manufacturer, 120),
|
||||
type: str(x.type, 60), interface: str(x.interface, 60), size_mb: num(x.disksize), serial: str(x.serial ?? x.serialnumber, 160),
|
||||
}));
|
||||
|
||||
const volumes = asArray(c.drives).map((x) => ({
|
||||
mount: str(x.letter ?? x.volumn, 190), label: str(x.label, 190), filesystem: str(x.filesystem, 60),
|
||||
type: str(x.type, 60), total_mb: num(x.total), free_mb: num(x.free),
|
||||
}));
|
||||
|
||||
const networks = asArray(c.networks)
|
||||
.filter((x) => !(String(x.virtualdev) === '1' && !x.ipaddress))
|
||||
.map((x) => ({
|
||||
description: str(x.description), mac: str(x.macaddr, 40), ip: str(x.ipaddress, 64), mask: str(x.ipmask, 64),
|
||||
gateway: str(x.ipgateway, 64), type: str(x.type, 60), status: str(x.status, 40),
|
||||
}));
|
||||
|
||||
const monitors = asArray(c.monitors).map((x) => ({
|
||||
manufacturer: str(x.manufacturer, 120), model: str(x.caption ?? x.description, 190), serial: str(x.serial, 120),
|
||||
}));
|
||||
|
||||
const software = asArray(c.softwares)
|
||||
.map((x) => {
|
||||
const name = str(x.name);
|
||||
const category = str(x.system_category, 40);
|
||||
return {
|
||||
name, version: str(x.version, 120), publisher: str(x.publisher, 190), install_date: str(x.installdate ?? x.install_date, 40),
|
||||
category, is_component: isComponent(name, category) ? 1 : 0,
|
||||
};
|
||||
})
|
||||
.filter((s) => s.name);
|
||||
|
||||
// Resumenes para listado/KPIs.
|
||||
const hostname = str(hw.name || nd.name || printer.name || os.fqdn, 190);
|
||||
const upIfaces = networks.filter((n) => n.ip && !/^(127\.|169\.254\.|::1|fe80)/i.test(n.ip) && !n.ip.includes(':'));
|
||||
const mainNet = upIfaces.find((n) => /up|connected/i.test(n.status || '')) || upIfaces[0] || networks[0] || {};
|
||||
const ramMb = num(hw.memory) || memories.reduce((a, m) => a + (m.capacity_mb || 0), 0) || null;
|
||||
const realVolumes = volumes.filter((v) => v.total_mb > 0 && !/cd|dvd|removable|network|remote/i.test(v.type || ''));
|
||||
const diskTotal = realVolumes.reduce((a, v) => a + v.total_mb, 0) || null;
|
||||
const diskFree = diskTotal ? realVolumes.reduce((a, v) => a + (v.free_mb || 0), 0) : null;
|
||||
const maxUsed = realVolumes.length
|
||||
? Math.max(...realVolumes.map((v) => Math.round(100 - ((v.free_mb || 0) * 100) / v.total_mb)))
|
||||
: null;
|
||||
|
||||
const kind = isAgentless && printer.name ? 'printer' : detectKind(parsed, hw, os);
|
||||
const lastUser = str(hw.lastloggeduser ?? asArray(c.users)[0]?.login, 190);
|
||||
|
||||
const device = {
|
||||
deviceid: parsed.deviceid,
|
||||
hostname,
|
||||
kind,
|
||||
manufacturer: str(bios.smanufacturer ?? nd.manufacturer ?? printer.manufacturer, 160),
|
||||
model: str(bios.smodel ?? nd.model ?? printer.model ?? printer.name, 190),
|
||||
serial: str(bios.ssn ?? nd.serial ?? printer.serialnumber ?? printer.serial, 160),
|
||||
os_name: str(os.full_name ?? os.name ?? hw.osname, 190),
|
||||
os_version: str(os.version ?? hw.osversion, 120),
|
||||
os_arch: str(os.arch, 40),
|
||||
domain: str(os.domain ?? os.fqdn ?? hw.workgroup, 190),
|
||||
last_user: lastUser,
|
||||
ip_main: mainNet.ip || str(nd.ips?.[0] ?? nd.ip, 64) || null,
|
||||
mac_main: mainNet.mac || str(nd.mac, 40) || null,
|
||||
cpu_summary: cpus.length ? `${cpus[0].name || ''}${cpus.length > 1 ? ` x${cpus.length}` : ''}`.trim().slice(0, 255) || null : null,
|
||||
ram_mb: ramMb,
|
||||
disk_total_mb: diskTotal,
|
||||
disk_free_mb: diskFree,
|
||||
max_disk_used_pct: maxUsed,
|
||||
agent_version: str(c.versionclient, 80),
|
||||
tag: parsed.tag,
|
||||
itemtype: parsed.itemtype,
|
||||
};
|
||||
|
||||
// Lo que no tiene columna propia (impresoras/red SNMP, antivirus, video, bateria) se conserva
|
||||
// como JSON para mostrarlo en el detalle sin tocar el esquema por cada tipo de equipo.
|
||||
const extra = {};
|
||||
for (const key of ['printers', 'network_device', 'antivirus', 'videos', 'batteries', 'sounds']) {
|
||||
if (c[key] && (!Array.isArray(c[key]) || c[key].length)) extra[key] = c[key];
|
||||
}
|
||||
|
||||
return { device, cpus, memories, disks, volumes, networks, monitors, software, extra };
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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'] },
|
||||
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
|
||||
FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function createToken(label, hash, userId) {
|
||||
const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, created_by) VALUES (?, ?, ?)', [label, hash, userId]);
|
||||
return r.insertId;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
// ---------- 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 }) {
|
||||
await pool.query(
|
||||
'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ? WHERE id = ?',
|
||||
[status, assignedUserId, departmentId, notes, id]
|
||||
);
|
||||
}
|
||||
|
||||
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) })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
|
||||
import {
|
||||
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
|
||||
getTokens, postToken, patchToken, deleteToken,
|
||||
} from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente, solo admin/superadmin.
|
||||
const CAN_VIEW = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
|
||||
const CAN_MANAGE_TOKENS = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
||||
|
||||
router.use(requireAuth, requireRole(...CAN_VIEW));
|
||||
router.get('/dashboard', getDashboard);
|
||||
router.get('/software', getSoftware);
|
||||
router.get('/software/devices', getSoftwareDevices);
|
||||
router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens);
|
||||
router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken);
|
||||
router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken);
|
||||
router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken);
|
||||
router.get('/', getDevices);
|
||||
router.get('/:id', getDevice);
|
||||
router.get('/:id/raw', getRaw);
|
||||
router.put('/:id', putDevice);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,75 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { env } from '../../config/env.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
const STATUSES = ['active', 'retired'];
|
||||
const STALE_DAYS = 7;
|
||||
|
||||
export const listDevices = (filters) => repo.findAll({
|
||||
search: filters.q?.trim() || null,
|
||||
kind: filters.kind || null,
|
||||
status: filters.status || null,
|
||||
staleDays: filters.stale ? STALE_DAYS : null,
|
||||
diskFull: !!filters.diskFull,
|
||||
});
|
||||
|
||||
export async function getDevice(id) {
|
||||
const device = await repo.findById(id);
|
||||
if (!device) throw new HttpError(404, 'Equipo no encontrado');
|
||||
return device;
|
||||
}
|
||||
|
||||
export async function updateDevice(id, data, actor) {
|
||||
const current = await getDevice(id);
|
||||
const status = data.status ?? current.status;
|
||||
if (!STATUSES.includes(status)) throw new HttpError(400, 'Estado invalido');
|
||||
const next = {
|
||||
status,
|
||||
assignedUserId: data.assignedUserId === undefined ? current.assigned_user_id : data.assignedUserId || null,
|
||||
departmentId: data.departmentId === undefined ? current.department_id : data.departmentId || null,
|
||||
notes: data.notes === undefined ? current.notes : (String(data.notes).trim() || null),
|
||||
};
|
||||
await repo.updateManual(id, next);
|
||||
await logAudit({ userId: actor.id, action: 'update', entityType: 'device', entityId: id, detail: { hostname: current.hostname, ...next } });
|
||||
return getDevice(id);
|
||||
}
|
||||
|
||||
export async function getRaw(id) {
|
||||
const file = await repo.findRawFile(id);
|
||||
if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo');
|
||||
const full = path.join(env.storageRoot, 'inventories', path.basename(file));
|
||||
if (!fs.existsSync(full)) throw new HttpError(404, 'Archivo de inventario no encontrado');
|
||||
return { full, file };
|
||||
}
|
||||
|
||||
export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() || null, all: !!all });
|
||||
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
||||
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
||||
|
||||
export const listTokens = () => repo.listTokens();
|
||||
|
||||
// El token en claro se devuelve UNA sola vez (aqui); en BD queda solo el hash.
|
||||
export async function createToken(label, actor) {
|
||||
const clean = String(label || '').trim();
|
||||
if (!clean) throw new HttpError(400, 'La etiqueta es obligatoria');
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
const id = await repo.createToken(clean.slice(0, 120), crypto.createHash('sha256').update(token).digest('hex'), actor.id);
|
||||
await logAudit({ userId: actor.id, action: 'create', entityType: 'inventory_token', entityId: id, detail: { label: clean } });
|
||||
return { id, label: clean, token };
|
||||
}
|
||||
|
||||
export async function setTokenActive(id, active, actor) {
|
||||
await repo.setTokenActive(id, active);
|
||||
await logAudit({ userId: actor.id, action: active ? 'enable' : 'disable', entityType: 'inventory_token', entityId: id });
|
||||
return repo.listTokens();
|
||||
}
|
||||
|
||||
export async function removeToken(id, actor) {
|
||||
await repo.deleteToken(id);
|
||||
await logAudit({ userId: actor.id, action: 'delete', entityType: 'inventory_token', entityId: id });
|
||||
return repo.listTokens();
|
||||
}
|
||||
Reference in New Issue
Block a user