feat: version 4.16.0 -- Equipos: tipo de licencia y software agrupado por fabricante
Clasificador por reglas sin IA, correccion manual (migracion 040) y filas desplegables en ListView. Flyout por encima de popups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ export const putDevice = asyncHandler(async (req, res) => ok(res, await service.
|
||||
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 putSoftwareClassification = asyncHandler(async (req, res) => ok(res, await service.setSoftwareClassification(req.body.name, req.body.licenseType, req.currentUser)));
|
||||
|
||||
export const getRaw = asyncHandler(async (req, res) => {
|
||||
const { full, file } = await service.getRaw(Number(req.params.id));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { isWindowsBundled } from './softwareClassifier.js';
|
||||
|
||||
// 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
|
||||
@@ -169,9 +170,10 @@ export function normalizeInventory(parsed) {
|
||||
.map((x) => {
|
||||
const name = str(x.name);
|
||||
const category = str(x.system_category, 40);
|
||||
const publisher = str(x.publisher, 190);
|
||||
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,
|
||||
name, version: str(x.version, 120), publisher, install_date: str(x.installdate ?? x.install_date, 40),
|
||||
category, is_component: isComponent(name, category) || isWindowsBundled(name, publisher, category) ? 1 : 0,
|
||||
};
|
||||
})
|
||||
.filter((s) => s.name);
|
||||
|
||||
@@ -244,8 +244,8 @@ export async function listSoftware({ search, all }) {
|
||||
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
|
||||
`SELECT s.name, MAX(s.publisher) AS publisher, MAX(s.category) AS category, MAX(s.is_component) AS is_component,
|
||||
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
|
||||
@@ -253,6 +253,19 @@ export async function listSoftware({ search, all }) {
|
||||
return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) }));
|
||||
}
|
||||
|
||||
export async function softwareOverrides() {
|
||||
const [rows] = await pool.query('SELECT name, license_type FROM software_classifications');
|
||||
return new Map(rows.map((r) => [r.name.toLowerCase(), r.license_type]));
|
||||
}
|
||||
|
||||
export const saveSoftwareClassification = (name, type, userId) => pool.query(
|
||||
`INSERT INTO software_classifications (name, license_type, updated_by) VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE license_type = VALUES(license_type), updated_by = VALUES(updated_by)`,
|
||||
[name, type, userId]
|
||||
);
|
||||
|
||||
export const removeSoftwareClassification = (name) => pool.query('DELETE FROM software_classifications WHERE name = ?', [name]);
|
||||
|
||||
export async function listDevicesWithSoftware(name) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT d.id, d.hostname, d.kind, d.last_user, s.version
|
||||
|
||||
@@ -5,7 +5,7 @@ import { requireModule } from '../../middleware/modules.js';
|
||||
import { fail } from '../../utils/http.js';
|
||||
import { getAccess as getAccessFor } from './service.js';
|
||||
import {
|
||||
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
|
||||
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, putSoftwareClassification, getRaw,
|
||||
getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer, postCpuYear,
|
||||
} from './controller.js';
|
||||
|
||||
@@ -31,6 +31,7 @@ router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para
|
||||
router.get('/dashboard', canView, getDashboard);
|
||||
router.get('/software', canView, getSoftware);
|
||||
router.get('/software/devices', canView, getSoftwareDevices);
|
||||
router.put('/software/classification', requireRole(...CAN_EDIT_DEVICES), putSoftwareClassification);
|
||||
router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers);
|
||||
router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer);
|
||||
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||
import { askStructured } from '../../utils/ai.js';
|
||||
import * as repo from './repository.js';
|
||||
import { parseInWorker } from './ingest.js';
|
||||
import { classifySoftware, LICENSE_TYPES } from './softwareClassifier.js';
|
||||
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
||||
|
||||
const STATUSES = ['active', 'retired'];
|
||||
@@ -26,6 +27,8 @@ export async function getDevice(id) {
|
||||
const device = await repo.findById(id);
|
||||
if (!device) throw new HttpError(404, 'Equipo no encontrado');
|
||||
if (device.extra?.licenses === undefined && device.raw_file) await backfillLicenses(device);
|
||||
const overrides = await repo.softwareOverrides();
|
||||
device.software = device.software.map((s) => ({ ...s, ...classifySoftware(s, overrides) }));
|
||||
return device;
|
||||
}
|
||||
|
||||
@@ -91,7 +94,23 @@ export async function getRaw(id) {
|
||||
return { full, file };
|
||||
}
|
||||
|
||||
export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() || null, all: !!all });
|
||||
export async function listSoftware(q, all) {
|
||||
const [rows, overrides] = await Promise.all([repo.listSoftware({ search: q?.trim() || null, all: !!all }), repo.softwareOverrides()]);
|
||||
const classified = rows.map((r) => ({ ...r, ...classifySoftware(r, overrides) }));
|
||||
return all ? classified : classified.filter((r) => !r.is_component);
|
||||
}
|
||||
|
||||
// Correccion manual del tipo de licencia de un programa; `licenseType` vacio vuelve a las reglas.
|
||||
export async function setSoftwareClassification(name, licenseType, actor) {
|
||||
const program = String(name || '').trim();
|
||||
if (!program || program.length > 255) throw new HttpError(400, 'Programa invalido');
|
||||
if (licenseType && !LICENSE_TYPES.includes(licenseType)) throw new HttpError(400, 'Tipo de licencia invalido');
|
||||
if (licenseType) await repo.saveSoftwareClassification(program, licenseType, actor.id);
|
||||
else await repo.removeSoftwareClassification(program);
|
||||
await logAudit({ userId: actor.id, action: 'classify_software', entityType: 'software', entityId: null, detail: { name: program, licenseType: licenseType || null } });
|
||||
return { name: program, licenseType: licenseType || null };
|
||||
}
|
||||
|
||||
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
||||
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Clasificador de software por reglas, sin IA ni consultas externas. Solo mira nombre, fabricante y
|
||||
// categoria que reporta el agente. Es orientativo: lo que no esta en las listas queda sin clasificar
|
||||
// (null) y un editor lo corrige a mano (software_classifications), nunca se adivina.
|
||||
|
||||
export const LICENSE_TYPES = ['free', 'paid', 'personal_free', 'component'];
|
||||
|
||||
// Fabricante normalizado: variantes de publisher y programas que solo lo nombran en el titulo.
|
||||
const VENDORS = [
|
||||
['Epson', /\bepson\b/i], ['Microsoft', /\bmicrosoft\b/i], ['Adobe', /\badobe\b/i], ['Google', /\bgoogle\b/i],
|
||||
['HP', /\b(hp|hewlett[- ]packard)\b/i], ['Canon', /\bcanon\b/i], ['Brother', /\bbrother\b/i],
|
||||
['Intel', /\bintel\b/i], ['Realtek', /\brealtek\b/i], ['NVIDIA', /\bnvidia\b/i], ['AMD', /\b(amd|advanced micro devices)\b/i],
|
||||
['Lenovo', /\blenovo\b/i], ['ASUS', /\basus(tek)?\b/i], ['Samsung', /\bsamsung\b/i], ['Conexant', /\bconexant\b/i],
|
||||
['OpenVPN', /\bopenvpn\b/i], ['Notepad++', /\bnotepad\+\+/i], ['Dell', /\bdell\b/i], ['Logitech', /\blogitech\b/i], ['Autodesk', /\bautodesk\b/i],
|
||||
['Mozilla', /\bmozilla\b/i], ['Oracle', /\boracle\b/i],
|
||||
];
|
||||
// Traen sus programas con el hardware: lo que no sea un producto de licencia va como incluido.
|
||||
const HARDWARE_VENDORS = new Set(['Epson', 'HP', 'Canon', 'Brother', 'Intel', 'Realtek', 'NVIDIA', 'AMD', 'Lenovo', 'Dell', 'Logitech', 'ASUS', 'Samsung', 'Conexant']);
|
||||
|
||||
const LEGAL_SUFFIX = /[,.\s]+(corporation|corp|incorporated|inc|ltd|limited|llc|gmbh|s\.?a\.?|co)\.?$/i;
|
||||
|
||||
export function vendorOf(name, publisher) {
|
||||
for (const [vendor, pattern] of VENDORS) if (pattern.test(publisher || '')) return vendor;
|
||||
for (const [vendor, pattern] of VENDORS) if (pattern.test(name || '')) return vendor;
|
||||
// Los paquetes de la tienda reportan el firmante ("CN=..."): no sirve como nombre visible.
|
||||
if (!publisher || /^cn=/i.test(publisher)) return null;
|
||||
return publisher.replace(LEGAL_SUFFIX, '').replace(LEGAL_SUFFIX, '').trim() || null;
|
||||
}
|
||||
|
||||
// Productos Microsoft con licencia propia: nunca se esconden dentro del grupo.
|
||||
const MICROSOFT_LICENSED = new RegExp([
|
||||
'^(microsoft )?windows (10|11|server)\\b(?!.*(asistente|assistant))',
|
||||
'^microsoft 365\\b(?!.*local ai)', '^microsoft office (profesional|professional|hogar|home|est[aá]ndar|standard|ltsc|\\d{4})',
|
||||
'^microsoft (visio|project)\\b', '^(microsoft )?visual studio (community|professional|enterprise|\\d{4})',
|
||||
'^microsoft sql server \\d{4}', '^microsoft dynamics', '^microsoft exchange server',
|
||||
].join('|'), 'i');
|
||||
|
||||
const RULES = [
|
||||
// Antes que las demas: nombres que tambien entrarian como pago/gratuito.
|
||||
['personal_free', /teamviewer|anydesk|winrar|\bwinzip\b|ccleaner|malwarebytes|avast free|avg (anti|free)|macrium reflect free|docker desktop|^java( \d+)?( update| se)|^java\(tm\)|sketchup free/i],
|
||||
['free', /acrobat reader|adobe reader|sql server.*express|visual studio code|^microsoft edge\b/i],
|
||||
['paid', new RegExp([
|
||||
'acrobat', 'photoshop', 'illustrator', 'indesign', 'premiere pro', 'after effects', 'lightroom',
|
||||
'autocad', 'revit', 'sketchup', 'coreldraw', 'solidworks', 'matlab', 'abbyy finereader', 'nitro pro',
|
||||
'foxit (pdf )?editor', 'realvnc server', 'kaspersky', '\\beset\\b', 'bitdefender', 'norton', 'mcafee',
|
||||
].join('|'), 'i')],
|
||||
['free', new RegExp([
|
||||
'^7-zip', '^vlc\\b', '^google chrome', 'mozilla (firefox|thunderbird)', '^notepad\\+\\+', 'libreoffice', 'openoffice',
|
||||
'^putty\\b', '^winscp', '^filezilla', '^git\\b', '^node\\.js', '^python \\d', '^gimp', '^inkscape', '^paint\\.net',
|
||||
'^pdfcreator', '^audacity', '^keepass', 'openvpn', '^tap-windows', '^xampp', '^mysql (workbench|connector)', '^firmaec', '^nextcloud',
|
||||
'^rustdesk', '^freefilesync', '^glpi agent', '^android studio', '^composer\\b', '^python launcher', '^nmap\\b',
|
||||
].join('|'), 'i')],
|
||||
];
|
||||
|
||||
function ruleType(name) {
|
||||
for (const [type, pattern] of RULES) if (pattern.test(name)) return type;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Apps de la tienda y paquetes que vienen con Windows: mismo trato que actualizaciones y drivers.
|
||||
const WINDOWS_BUNDLED = /desktop runtime|winappruntime|ink\.handwriting|windows web experience|windows package manager|experiencia local|language experience pack|widgets platform/i;
|
||||
const REAL_STORE_APP = /whiteboard|teams|to do|onenote|outlook|powerpoint|\bexcel\b|\bword\b|clipchamp|power bi|company portal|remote desktop|visio|project/i;
|
||||
|
||||
export function isWindowsBundled(name, publisher, category) {
|
||||
if (WINDOWS_BUNDLED.test(name || '')) return true;
|
||||
return String(category || '').toLowerCase() === 'store' && vendorOf(name, publisher) === 'Microsoft' && !REAL_STORE_APP.test(name || '');
|
||||
}
|
||||
|
||||
// Devuelve { is_component, license_type, vendor, group }: `group` es el fabricante bajo el que se
|
||||
// colapsa la fila, null si debe verse suelta. `overrides` es un Map(nombre en minusculas -> tipo).
|
||||
export function classifySoftware(row, overrides) {
|
||||
const name = row.name || '';
|
||||
const vendor = vendorOf(name, row.publisher);
|
||||
const isComponent = !!row.is_component || isWindowsBundled(name, row.publisher, row.category);
|
||||
const licensed = vendor === 'Microsoft' && MICROSOFT_LICENSED.test(name);
|
||||
let type = isComponent ? 'component' : licensed ? 'paid' : ruleType(name);
|
||||
if (!type && (vendor === 'Microsoft' || HARDWARE_VENDORS.has(vendor))) type = 'component';
|
||||
const manual = overrides?.get(name.toLowerCase());
|
||||
return {
|
||||
is_component: isComponent ? 1 : 0,
|
||||
license_type: manual || type,
|
||||
license_manual: !!manual,
|
||||
vendor,
|
||||
group: licensed ? null : vendor,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user