From 420a794c9e032a6d772b8207df3c176078bd10b3 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Fri, 30 Oct 2026 12:36:02 -0500 Subject: [PATCH] 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 --- API.md | 5 +- BASE_DE_DATOS.md | 1 + CHANGELOG.md | 16 +++ ESTADO.md | 4 + VERSION | 2 +- .../040_software_classifications.sql | 10 ++ backend/package.json | 2 +- backend/src/modules/devices/controller.js | 1 + backend/src/modules/devices/normalizer.js | 6 +- backend/src/modules/devices/repository.js | 17 ++- backend/src/modules/devices/routes.js | 3 +- backend/src/modules/devices/service.js | 21 +++- .../src/modules/devices/softwareClassifier.js | 85 +++++++++++++ docs/ARQUITECTURA-INVENTARIO.md | 9 ++ frontend/package.json | 2 +- frontend/src/components/ListView.jsx | 116 ++++++++++++------ frontend/src/i18n/messages.js | 28 +++++ frontend/src/pages/Equipos/EquiposPage.jsx | 53 +++++--- frontend/src/pages/Equipos/SoftwareList.jsx | 95 ++++++++++++++ frontend/src/services/devices.js | 1 + frontend/src/styles/app.css | 5 +- 21 files changed, 412 insertions(+), 70 deletions(-) create mode 100644 backend/database/migrations/040_software_classifications.sql create mode 100644 backend/src/modules/devices/softwareClassifier.js create mode 100644 frontend/src/pages/Equipos/SoftwareList.jsx diff --git a/API.md b/API.md index f1746fd..e31daa2 100644 --- a/API.md +++ b/API.md @@ -230,7 +230,10 @@ se registra en consola de desarrollo. Antes de usar estas rutas se debe aplicar - `GET /api/devices?q=&kind=&status=&stale=1&diskFull=1` · `GET /api/devices/:id` (con hardware, red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes, editVersion }`. - `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` · - `GET /api/devices/software/devices?name=` — software transversal. + `GET /api/devices/software/devices?name=` — software transversal. Cada programa trae `license_type` + (`free`/`paid`/`personal_free`/`component`/`null`), `license_manual`, `vendor` y `group`. +- `PUT /api/devices/software/classification` — `{ name, licenseType }`: corrige el tipo de un programa + en todos los equipos; `licenseType` vacío vuelve a las reglas. Roles que editan equipos; audita `classify_software`. - `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de dashboard/listado/detalle/software) también para usuarios con acceso compartido. - `GET/POST /api/devices/viewers` · `DELETE /api/devices/viewers/:userId` — compartir Equipos (admin/superadmin). diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index b60fe67..7b2a848 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -83,6 +83,7 @@ desde la UI. No hay migración aplicada ni conexión a BD en este cambio. | `devices` | inventario de equipos (menú "Equipos"), upsert por `deviceid` del agente GLPI; baja lógica con `status` | | `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte | | `device_accounts` | cuentas que reporta el agente: sesión iniciada, usuarios locales y grupos locales (`kind`), reemplazadas en cada reporte (migración `027`) | +| `software_classifications` | tipo de licencia corregido a mano por programa (`name`, `license_type`, `updated_by`); gana sobre las reglas (migración `040`) | | `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) | | `system_settings` (IA) | `ai_api_key_enc` (AES-256-GCM), `ai_model`, `ai_edit_version` (migración 031) | | `ai_usage` | consumo por llamada a la IA (`user_id`, `action`, `model`, `input_tokens`, `output_tokens`, `cost_usd` estimado, `created_at`) (migración 032) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6ba16..8d30dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog - project.linkedcorp.net +## [4.16.0] - 2026-10-30 +### Added +- Equipos → Software (detalle del equipo y pestaña general): columna "Tipo" y filtro con Gratuito, + Pago, Gratis solo personal, Componente o incluido y Sin clasificar. Clasificador por reglas de + nombre y fabricante, sin IA ni consultas externas (`softwareClassifier.js`). Es orientativo y no + detecta software sin licencia. Quien edita equipos puede corregir el tipo de un programa desde + el menú de la fila; vale para todos los equipos (migración `040`). +- Software agrupado por fabricante desde 2 programas, con fila desplegable (`ListView` admite + `getChildren`). Windows, Office y otros productos Microsoft con licencia quedan fuera del grupo. + El grupo cerrado muestra el tipo de mayor riesgo y la búsqueda abre los que coinciden. +### Changed +- Las apps de la tienda de Microsoft y los paquetes que vienen con Windows ahora cuentan como + componentes: se ocultan con el selector apagado. +### Fixed +- Los menús `Flyout` quedaban detrás de los popups y diálogos. + ## [4.15.1] - 2026-10-30 ### Fixed - La 4.15.0 se publicó con `VERSION` en 4.15.0 pero los `package.json` en 4.14.0: el frontend se diff --git a/ESTADO.md b/ESTADO.md index ff0463c..f7b9be5 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -6,6 +6,10 @@ autorregistro/aprobacion/SMTP. ## Conexiones externas (v4.12.0, en dev, sin desplegar) +- v4.16.0 (2026-10-30): Software con tipo de licencia, grupos por fabricante y corrección manual; + migración `040` aplicada en dev. Backend probado contra la BD de dev; pantalla probada con API + simulada (sin login real) y **falta probarla con un usuario real**. Pendiente: servidor LinkedCorp + `.3` sigue en 2.19.0 (decidir si se sube) y aplicar el actualizador en Exilaser. - Conector FoxPro **1.1.0 instalado en Exilaser** (`C:\api_project\foxpro`, puerto 8455, tarea `LinkedCorp-Conector-FoxPro`). Token en `~/.config/linkedcorp/conector-foxpro-exilaser.token`. - **Módulo del sistema hecho en dev**: migración 038 aplicada en dev, `modules/integrations`, job diff --git a/VERSION b/VERSION index fb05571..ecbc3b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.15.1 +4.16.0 diff --git a/backend/database/migrations/040_software_classifications.sql b/backend/database/migrations/040_software_classifications.sql new file mode 100644 index 0000000..62fdd28 --- /dev/null +++ b/backend/database/migrations/040_software_classifications.sql @@ -0,0 +1,10 @@ +-- Tipo de licencia de un programa corregido a mano (gratuito, pago, gratis solo personal o +-- componente). Vale para todos los equipos que lo tengan y gana sobre el clasificador por reglas +-- (softwareClassifier.js), que no se guarda: se calcula al leer. Idempotente. +CREATE TABLE IF NOT EXISTS software_classifications ( + name VARCHAR(255) NOT NULL PRIMARY KEY, + license_type VARCHAR(20) NOT NULL, + updated_by INT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_software_classifications_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package.json b/backend/package.json index 7ca8196..4e1595c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.15.1", + "version": "4.16.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/devices/controller.js b/backend/src/modules/devices/controller.js index c850545..5c8cc3f 100644 --- a/backend/src/modules/devices/controller.js +++ b/backend/src/modules/devices/controller.js @@ -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)); diff --git a/backend/src/modules/devices/normalizer.js b/backend/src/modules/devices/normalizer.js index d1b54e9..c37f5ee 100644 --- a/backend/src/modules/devices/normalizer.js +++ b/backend/src/modules/devices/normalizer.js @@ -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); diff --git a/backend/src/modules/devices/repository.js b/backend/src/modules/devices/repository.js index 6ecf1d1..069a44c 100644 --- a/backend/src/modules/devices/repository.js +++ b/backend/src/modules/devices/repository.js @@ -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 diff --git a/backend/src/modules/devices/routes.js b/backend/src/modules/devices/routes.js index 0c7d9ed..729586f 100644 --- a/backend/src/modules/devices/routes.js +++ b/backend/src/modules/devices/routes.js @@ -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); diff --git a/backend/src/modules/devices/service.js b/backend/src/modules/devices/service.js index 0c24221..573a461 100644 --- a/backend/src/modules/devices/service.js +++ b/backend/src/modules/devices/service.js @@ -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); diff --git a/backend/src/modules/devices/softwareClassifier.js b/backend/src/modules/devices/softwareClassifier.js new file mode 100644 index 0000000..578d0fd --- /dev/null +++ b/backend/src/modules/devices/softwareClassifier.js @@ -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, + }; +} diff --git a/docs/ARQUITECTURA-INVENTARIO.md b/docs/ARQUITECTURA-INVENTARIO.md index ab60179..c80f313 100644 --- a/docs/ARQUITECTURA-INVENTARIO.md +++ b/docs/ARQUITECTURA-INVENTARIO.md @@ -56,6 +56,15 @@ ausente queda `null` (el formato varía por versión del agente y SO). Tipo de e nombre del SO e `itemtype`. Lo que no tiene columna (impresoras/red SNMP, antivirus, video, batería) se guarda en `devices.extra_json`. +## Clasificación de software + +`softwareClassifier.js` asigna el tipo y el fabricante **al leer**, con reglas de nombre y fabricante +(sin IA ni consultas externas): por eso una regla nueva aplica sin esperar otro reporte. El agente no +informa si un programa es gratis o de pago ni si tiene licencia; el resultado es orientativo. Las +correcciones manuales viven en `software_classifications`. Lo que no está en las listas queda sin +clasificar: no se adivina. La pantalla agrupa por fabricante; los productos Microsoft con licencia +quedan fuera del grupo. + ## Instalar el agente en un equipo 1. Superadmin/admin: menú **Equipos → Agentes → Generar token** (se muestra una sola vez). diff --git a/frontend/package.json b/frontend/package.json index 0910700..4fc3338 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.15.1", + "version": "4.16.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/ListView.jsx b/frontend/src/components/ListView.jsx index b955f3f..6b93fc4 100644 --- a/frontend/src/components/ListView.jsx +++ b/frontend/src/components/ListView.jsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from 'react'; -import { MoreVertical, GripVertical } from 'lucide-react'; +import { Fragment, useMemo, useState } from 'react'; +import { MoreVertical, GripVertical, ChevronRight, ChevronDown } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; import { useDragReorder } from '../hooks/useDragReorder.js'; import { Flyout, FlyoutItem } from './Flyout.jsx'; @@ -9,15 +9,25 @@ import { Flyout, FlyoutItem } from './Flyout.jsx'; // `onReorder(idsEnNuevoOrden)` es opcional: si se pasa, habilita arrastrar filas con el mouse para // definir un orden manual (columna con icono de agarre a la izquierda) — solo mientras no haya un // ordenamiento por columna activo, para no mezclar los dos mecanismos de orden a la vez. -export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort, onReorder }) { +// `getChildren(fila)` es opcional: si devuelve filas, la fila se pliega/despliega con su boton o con +// un clic y las hijas se muestran debajo. Al buscar, solo quedan las hijas que coinciden y se abren. +export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort, onReorder, getChildren }) { const { t } = useUi(); const [sort, setSort] = useState(initialSort || { key: null, dir: 1 }); + const [expanded, setExpanded] = useState(() => new Set()); + const haystack = (r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase(); + const toggleRow = (key) => setExpanded((prev) => { + const next = new Set(prev); + if (!next.delete(key)) next.add(key); + return next; + }); const filtered = useMemo(() => { if (!searchValue) return rows; const q = searchValue.toLowerCase(); - return rows.filter((r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase().includes(q)); - }, [rows, searchValue, getSearchHaystack]); + return rows.filter((r) => haystack(r).includes(q) || getChildren?.(r)?.some((c) => haystack(c).includes(q))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows, searchValue, getSearchHaystack, getChildren]); const sorted = useMemo(() => { if (!sort.key) return filtered; @@ -38,6 +48,55 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 })); } + function renderRow(row, { isParent, open, onToggle, child } = {}) { + const actions = rowActions?.(row) || []; + const activate = isParent ? onToggle : onRowClick ? () => onRowClick(row) : null; + return ( + activate?.()} + onContextMenu={actions.length ? (event) => { event.preventDefault(); event.currentTarget.querySelector('.row-menu-btn')?.click(); } : undefined} + tabIndex={activate ? 0 : undefined} + onKeyDown={(event) => { if (activate && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); activate(); } }} + className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''} + {...(dragEnabled ? getRowProps(row) : {})} + > + {dragEnabled && ( + e.stopPropagation()} {...getHandleProps(row)}> + + + )} + {columns.map((col, index) => ( + + {index === 0 && isParent ? ( + + + {col.render ? col.render(row) : row[col.key]} + + ) : (col.render ? col.render(row) : row[col.key])} + + ))} + {rowActions && ( + e.stopPropagation()}> + {actions.length > 0 && ( + ( + + )}> + {actions.map((action, i) => ( + + {action.label} + + ))} + + )} + + )} + + ); + } + return (
@@ -53,39 +112,20 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC - {displayRows.map((row) => ( - onRowClick?.(row)} - onContextMenu={rowActions ? (event) => { event.preventDefault(); event.currentTarget.querySelector('.row-menu-btn')?.click(); } : undefined} - tabIndex={onRowClick ? 0 : undefined} - onKeyDown={(event) => { if (onRowClick && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onRowClick(row); } }} - className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''} - {...(dragEnabled ? getRowProps(row) : {})} - > - {dragEnabled && ( - - )} - {columns.map((col) => ( - - ))} - {rowActions && ( - - )} - - ))} + {displayRows.map((row) => { + const key = getRowKey(row); + const allChildren = getChildren?.(row); + const isParent = !!allChildren?.length; + const q = searchValue?.toLowerCase(); + const children = isParent && q ? allChildren.filter((c) => haystack(c).includes(q)) : allChildren; + const open = isParent && (expanded.has(key) || (!!q && children.length > 0)); + return ( + + {renderRow(row, { isParent, open, onToggle: () => toggleRow(key) })} + {open && children.map((child) => renderRow(child, { child: true }))} + + ); + })}
e.stopPropagation()} {...getHandleProps(row)}> - - {col.render ? col.render(row) : row[col.key]} e.stopPropagation()}> - ( - - )}> - {rowActions(row).map((action, i) => ( - - {action.label} - - ))} - -
{displayRows.length === 0 &&
{t('common_empty')}
} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 171a9df..7feb7ef 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -294,6 +294,8 @@ export const messages = { common_search: 'Buscar...', common_select: 'Seleccionar', common_actions: 'Acciones', + common_expand: 'Desplegar', + common_collapse: 'Plegar', common_search_no_results: 'Sin resultados', common_actions: 'Acciones', common_view: 'Ver', @@ -498,6 +500,18 @@ export const messages = { eq_after: 'Después', eq_history_empty: 'Sin cambios registrados entre reportes.', eq_tab_licenses: 'Licenciamiento', + eq_sw_type: 'Tipo', + eq_sw_type_all: 'Todos los tipos', + eq_sw_type_free: 'Gratuito', + eq_sw_type_paid: 'Pago', + eq_sw_type_personal_free: 'Gratis solo personal', + eq_sw_type_component: 'Componente o incluido', + eq_sw_type_unclassified: 'Sin clasificar', + eq_sw_programs: 'programas', + eq_sw_classify_as: 'Marcar como', + eq_sw_classify_auto: 'Volver a automático', + eq_sw_manual: 'Tipo corregido a mano', + eq_sw_hint: 'Clasificación orientativa por nombre y fabricante: no es un dictamen legal ni detecta software sin licencia.', eq_lic_windows: 'Windows', eq_lic_office: 'Office', eq_lic_office_installed: 'Office instalado', @@ -1076,6 +1090,8 @@ export const messages = { common_search: 'Search...', common_select: 'Select', common_actions: 'Actions', + common_expand: 'Expand', + common_collapse: 'Collapse', common_search_no_results: 'No results', common_actions: 'Actions', common_view: 'View', @@ -1280,6 +1296,18 @@ export const messages = { eq_after: 'After', eq_history_empty: 'No changes recorded between reports.', eq_tab_licenses: 'Licensing', + eq_sw_type: 'Type', + eq_sw_type_all: 'All types', + eq_sw_type_free: 'Free', + eq_sw_type_paid: 'Paid', + eq_sw_type_personal_free: 'Free for personal use only', + eq_sw_type_component: 'Component or bundled', + eq_sw_type_unclassified: 'Unclassified', + eq_sw_programs: 'programs', + eq_sw_classify_as: 'Mark as', + eq_sw_classify_auto: 'Back to automatic', + eq_sw_manual: 'Type set manually', + eq_sw_hint: 'Indicative classification by name and publisher: it is not a legal opinion and does not detect unlicensed software.', eq_lic_windows: 'Windows', eq_lic_office: 'Office', eq_lic_office_installed: 'Installed Office', diff --git a/frontend/src/pages/Equipos/EquiposPage.jsx b/frontend/src/pages/Equipos/EquiposPage.jsx index 2ed1d9f..b81328a 100644 --- a/frontend/src/pages/Equipos/EquiposPage.jsx +++ b/frontend/src/pages/Equipos/EquiposPage.jsx @@ -23,6 +23,7 @@ import { ListView } from '../../components/ListView.jsx'; import { Tabs } from '../../components/Tabs.jsx'; import { Badge } from '../../components/Badge.jsx'; import { KpiCard } from '../../components/KpiCard.jsx'; +import { SoftwareList, SoftwareTypeFilter, matchesSoftwareType } from './SoftwareList.jsx'; import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js'; const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other']; @@ -69,6 +70,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=) const [searches, setSearches] = useState({}); const [showComponents, setShowComponents] = useState(false); + const [softwareType, setSoftwareType] = useState(''); const [fetchingYear, setFetchingYear] = useState(false); const [editing, setEditing] = useState(false); // Borrador de los datos editables del equipo (estado, asignado, departamento, notas). @@ -193,7 +195,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC const localUsers = only(allLocalUsers, (r) => [r.name, r.sid]); const localGroups = only(allLocalGroups, (r) => [r.name, r.sid]); const networks = only(device.networks, (r) => [r.description, r.ip, r.mask, r.gateway, r.mac, r.status]); - const software = only(device.software.filter((r) => showComponents || !r.is_component), (r) => [r.name, r.version, r.publisher]); + const software = device.software.filter((r) => (showComponents || !r.is_component) && matchesSoftwareType(r, softwareType)); const win = device.extra?.licenses?.windows; const channel = (oem) => (oem == null ? null : oem ? t('eq_lic_oem') : t('eq_lic_not_oem')); const winRows = win ? [ @@ -213,10 +215,13 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
{tab === 'software' ? ( - +
+ + +
) :
} setSearches({ ...searches, [tab]: v })} />
@@ -304,9 +309,14 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC )} {tab === 'software' && ( - + dash(r.version), groupRender: () => '—' }, + { key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) }, + ]} + /> )} {tab === 'licenses' && ( @@ -446,34 +456,37 @@ function DevicesTab({ canEdit }) { ); } -function SoftwareTab() { +function SoftwareTab({ canEdit }) { const { t } = useUi(); const [search, setSearch] = useState(''); const [selected, setSelected] = useState(null); const [showComponents, setShowComponents] = useState(false); - const { data: rows } = useAsync(() => listSoftware(showComponents), [showComponents]); + const [softwareType, setSoftwareType] = useState(''); + const { data: rows, reload } = useAsync(() => listSoftware(showComponents), [showComponents]); const { data: installs } = useAsync(() => (selected ? listSoftwareDevices(selected) : Promise.resolve([])), [selected]); if (!rows) return
{t('common_loading')}
; const columns = [ { key: 'name', label: t('common_name'), render: (r) => {r.name} }, { key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) }, - { key: 'version_count', label: t('eq_versions') }, + { key: 'version_count', label: t('eq_versions'), groupRender: () => '—' }, { key: 'device_count', label: t('eq_installed_on') }, ]; return ( <>
- +
+ + +
- r.name} - getSearchHaystack={(r) => `${r.name} ${r.publisher || ''}`} onRowClick={(r) => setSelected(r.name)} - initialSort={{ key: 'device_count', dir: -1 }} + matchesSoftwareType(r, softwareType))} columns={columns} searchValue={search} canEdit={canEdit} + onChanged={reload} onRowClick={(r) => setSelected(r.name)} initialSort={{ key: 'device_count', dir: -1 }} /> setSelected(null)} width={620} height={520}> {tab === 'summary' && } {tab === 'devices' && } - {tab === 'software' && } + {tab === 'software' && } {tab === 'agents' && access.canManage && } {tab === 'share' && access.canManage && }
diff --git a/frontend/src/pages/Equipos/SoftwareList.jsx b/frontend/src/pages/Equipos/SoftwareList.jsx new file mode 100644 index 0000000..e580114 --- /dev/null +++ b/frontend/src/pages/Equipos/SoftwareList.jsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react'; +import { Tag, RotateCcw } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAction } from '../../hooks/useAsync.js'; +import { setSoftwareClassification } from '../../services/devices.js'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { ListView } from '../../components/ListView.jsx'; +import { Badge } from '../../components/Badge.jsx'; + +const TYPES = ['free', 'paid', 'personal_free', 'component']; +const TYPE_COLOR = { free: 'success', paid: 'accent', personal_free: 'warning', component: 'muted' }; +// El grupo cerrado muestra el tipo de mayor riesgo que tenga dentro: nada queda escondido. +const RISK = ['personal_free', 'paid', null, 'free', 'component']; +const typeKey = (type) => type || 'unclassified'; +const getChildren = (row) => row.children; +const getRowKey = (row) => row.groupKey || row.id || row.name; + +export function matchesSoftwareType(row, filter) { + return !filter || typeKey(row.license_type) === filter; +} + +// Agrupa por fabricante desde 2 programas; lo que no tiene grupo sigue como fila normal. +function groupRows(rows) { + const groups = new Map(); + for (const row of rows) if (row.group) groups.set(row.group, [...(groups.get(row.group) || []), row]); + const seen = new Set(); + const result = []; + for (const row of rows) { + const children = row.group ? groups.get(row.group) : null; + if (!children || children.length < 2) { result.push(row); continue; } + if (seen.has(row.group)) continue; + seen.add(row.group); + result.push({ + groupKey: `group:${row.group}`, name: row.group, children, + publisher: children.find((c) => c.publisher)?.publisher || null, + license_type: RISK.find((type) => children.some((c) => (c.license_type || null) === type)) ?? null, + device_count: Math.max(...children.map((c) => c.device_count || 0)), + }); + } + return result; +} + +export function SoftwareTypeFilter({ value, onChange }) { + const { t } = useUi(); + const options = [{ value: '', label: t('eq_sw_type_all') }, ...[...TYPES, 'unclassified'].map((k) => ({ value: k, label: t(`eq_sw_type_${k}`) }))]; + return
; +} + +export function SoftwareList({ rows, columns, searchValue, canEdit, onChanged, onRowClick, initialSort }) { + const { t, pushToast } = useUi(); + const runAction = useAction(); + const grouped = useMemo(() => groupRows(rows), [rows]); + + function classify(row, licenseType) { + runAction(async () => { + await setSoftwareClassification(row.name, licenseType); + await onChanged(); + pushToast({ tone: 'success', message: t('common_saved') }); + }).catch(() => {}); + } + + const allColumns = [ + ...columns.map((col, index) => (index === 0 ? { + ...col, + render: (r) => (r.children ? {r.name} · {r.children.length} {t('eq_sw_programs')} : col.render ? col.render(r) : r[col.key]), + } : { ...col, render: (r) => (r.children && col.groupRender ? col.groupRender(r) : col.render ? col.render(r) : r[col.key]) })), + { + key: 'license_type', label: t('eq_sw_type'), sortValue: (r) => RISK.indexOf(r.license_type || null), + render: (r) => ( + + {r.license_type + ? {t(`eq_sw_type_${r.license_type}`)}{r.license_manual ? ' *' : ''} + : {t('eq_sw_type_unclassified')}} + + ), + }, + ]; + const rowActions = canEdit ? (r) => (r.children ? [] : [ + ...TYPES.filter((type) => type !== r.license_type).map((type) => ({ + icon: Tag, label: `${t('eq_sw_classify_as')}: ${t(`eq_sw_type_${type}`)}`, onClick: () => classify(r, type), + })), + ...(r.license_manual ? [{ icon: RotateCcw, label: t('eq_sw_classify_auto'), onClick: () => classify(r, null) }] : []), + ]) : undefined; + + return ( + <> + `${r.name} ${r.publisher || ''} ${r.version || ''}`} + onRowClick={onRowClick} rowActions={rowActions} initialSort={initialSort} + /> +
{t('eq_sw_hint')}
+ + ); +} diff --git a/frontend/src/services/devices.js b/frontend/src/services/devices.js index 9871b90..9c5413f 100644 --- a/frontend/src/services/devices.js +++ b/frontend/src/services/devices.js @@ -8,6 +8,7 @@ export const getDevice = (id) => api.get(`/devices/${id}`); export const updateDevice = (id, data) => api.put(`/devices/${id}`, data); export const getDevicesDashboard = () => api.get('/devices/dashboard'); export const listSoftware = (all) => api.get(`/devices/software${all ? '?all=1' : ''}`); +export const setSoftwareClassification = (name, licenseType) => api.put('/devices/software/classification', { name, licenseType }); export const listSoftwareDevices = (name) => api.get(`/devices/software/devices?name=${encodeURIComponent(name)}`); export const listInventoryTokens = () => api.get('/devices/tokens'); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 249b5e3..7365220 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -230,6 +230,7 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; } .list-table tbody tr { cursor: pointer; } .list-table tbody tr:hover { background: rgb(var(--panel)); } .list-table tbody tr:last-child td { border-bottom: none; } +.list-tree-cell { display: inline-flex; align-items: center; gap: 6px; } .list-empty { padding: 40px 16px; text-align: center; color: rgb(var(--muted)); } .row-menu-btn { opacity: .6; } .row-menu-btn:hover { opacity: 1; } @@ -336,9 +337,9 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; } .toast-error { border-left-color: rgb(var(--danger)); } /* --- Flyout / menú contextual --- */ -.flyout-backdrop { position: fixed; inset: 0; z-index: 90; } +.flyout-backdrop { position: fixed; inset: 0; z-index: 260; } .flyout-panel { - position: fixed; z-index: 100; background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); + position: fixed; z-index: 270; background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); box-shadow: 0 10px 28px rgb(0 0 0 / .35); padding: 6px; min-width: 180px; } .flyout-item {