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:
Milton Gonzalez
2026-10-30 12:36:02 -05:00
parent f5721f95a9
commit 420a794c9e
21 changed files with 412 additions and 70 deletions
+4 -1
View File
@@ -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).
+1
View File
@@ -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) |
+16
View File
@@ -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
+4
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
4.15.1
4.16.0
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "4.15.1",
"version": "4.16.0",
"private": true,
"type": "module",
"main": "src/server.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));
+4 -2
View File
@@ -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);
+15 -2
View File
@@ -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
+2 -1
View File
@@ -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);
+20 -1
View File
@@ -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,
};
}
+9
View File
@@ -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).
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "4.15.1",
"version": "4.16.0",
"type": "module",
"scripts": {
"dev": "vite",
+78 -38
View File
@@ -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 (
<tr
key={getRowKey(row)}
onClick={() => 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 && (
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()} {...getHandleProps(row)}>
<GripVertical size={15} />
</td>
)}
{columns.map((col, index) => (
<td key={col.key} style={index === 0 && child ? { paddingLeft: 38 } : undefined}>
{index === 0 && isParent ? (
<span className="list-tree-cell">
<button type="button" className="tree-toggle" aria-expanded={open} aria-label={t(open ? 'common_collapse' : 'common_expand')} onClick={(e) => { e.stopPropagation(); onToggle(); }}>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{col.render ? col.render(row) : row[col.key]}
</span>
) : (col.render ? col.render(row) : row[col.key])}
</td>
))}
{rowActions && (
<td onClick={(e) => e.stopPropagation()}>
{actions.length > 0 && (
<Flyout trigger={({ onClick }) => (
<button className="icon-btn row-menu-btn" aria-label={t('common_actions')} onClick={onClick}><MoreVertical /></button>
)}>
{actions.map((action, i) => (
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
{action.label}
</FlyoutItem>
))}
</Flyout>
)}
</td>
)}
</tr>
);
}
return (
<div className="list-card">
<table className="list-table">
@@ -53,39 +112,20 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
</tr>
</thead>
<tbody>
{displayRows.map((row) => (
<tr
key={getRowKey(row)}
onClick={() => 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 && (
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()} {...getHandleProps(row)}>
<GripVertical size={15} />
</td>
)}
{columns.map((col) => (
<td key={col.key}>{col.render ? col.render(row) : row[col.key]}</td>
))}
{rowActions && (
<td onClick={(e) => e.stopPropagation()}>
<Flyout trigger={({ onClick }) => (
<button className="icon-btn row-menu-btn" aria-label={t('common_actions')} onClick={onClick}><MoreVertical /></button>
)}>
{rowActions(row).map((action, i) => (
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
{action.label}
</FlyoutItem>
))}
</Flyout>
</td>
)}
</tr>
))}
{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 (
<Fragment key={key}>
{renderRow(row, { isParent, open, onToggle: () => toggleRow(key) })}
{open && children.map((child) => renderRow(child, { child: true }))}
</Fragment>
);
})}
</tbody>
</table>
{displayRows.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
+28
View File
@@ -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',
+33 -20
View File
@@ -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
<div style={{ marginTop: 12 }}>
<div className="toolbar" style={{ marginBottom: 10 }}>
{tab === 'software' ? (
<label className="checkbox-row">
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
{t('eq_show_components')}
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<label className="checkbox-row">
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
{t('eq_show_components')}
</label>
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
</div>
) : <div />}
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
</div>
@@ -304,9 +309,14 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
)}
{tab === 'software' && (
<SimpleTable empty={emptyText} rows={software} columns={[
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') }, { key: 'publisher', label: t('eq_publisher') },
]} />
<SoftwareList
rows={software} searchValue={searches.software} canEdit={canEdit} onChanged={reload} initialSort={{ key: 'name', dir: 1 }}
columns={[
{ key: 'name', label: t('common_name') },
{ key: 'version', label: t('eq_version'), render: (r) => 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 <div className="text-muted">{t('common_loading')}</div>;
const columns = [
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
{ 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 (
<>
<div className="toolbar">
<label className="checkbox-row">
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
{t('eq_show_components')}
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<label className="checkbox-row">
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
{t('eq_show_components')}
</label>
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
</div>
<SearchInput value={search} onChange={setSearch} />
</div>
<ListView
columns={columns} rows={rows} searchValue={search} getRowKey={(r) => r.name}
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''}`} onRowClick={(r) => setSelected(r.name)}
initialSort={{ key: 'device_count', dir: -1 }}
<SoftwareList
rows={rows.filter((r) => matchesSoftwareType(r, softwareType))} columns={columns} searchValue={search} canEdit={canEdit}
onChanged={reload} onRowClick={(r) => setSelected(r.name)} initialSort={{ key: 'device_count', dir: -1 }}
/>
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
@@ -674,7 +687,7 @@ export default function EquiposPage() {
<div style={{ marginTop: 14 }}>
{tab === 'summary' && <SummaryTab />}
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
{tab === 'software' && <SoftwareTab />}
{tab === 'software' && <SoftwareTab canEdit={access.canEdit} />}
{tab === 'agents' && access.canManage && <AgentsTab />}
{tab === 'share' && access.canManage && <ShareTab />}
</div>
@@ -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 <div style={{ width: 210 }}><ComboBox options={options} value={value} onChange={onChange} /></div>;
}
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 ? <span><strong>{r.name}</strong> <span className="text-muted">· {r.children.length} {t('eq_sw_programs')}</span></span> : 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) => (
<span title={r.license_manual ? t('eq_sw_manual') : undefined}>
{r.license_type
? <Badge color={TYPE_COLOR[r.license_type]}>{t(`eq_sw_type_${r.license_type}`)}{r.license_manual ? ' *' : ''}</Badge>
: <Badge color="danger">{t('eq_sw_type_unclassified')}</Badge>}
</span>
),
},
];
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 (
<>
<ListView
columns={allColumns} rows={grouped} searchValue={searchValue} getRowKey={getRowKey} getChildren={getChildren}
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''} ${r.version || ''}`}
onRowClick={onRowClick} rowActions={rowActions} initialSort={initialSort}
/>
<div className="text-muted" style={{ fontSize: '.72rem', marginTop: 12 }}>{t('eq_sw_hint')}</div>
</>
);
}
+1
View File
@@ -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');
+3 -2
View File
@@ -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 {