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:
@@ -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,
|
- `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 }`.
|
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/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
|
- `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.
|
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).
|
- `GET/POST /api/devices/viewers` · `DELETE /api/devices/viewers/:userId` — compartir Equipos (admin/superadmin).
|
||||||
|
|||||||
@@ -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` |
|
| `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_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`) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `ai_usage` | consumo por llamada a la IA (`user_id`, `action`, `model`, `input_tokens`, `output_tokens`, `cost_usd` estimado, `created_at`) (migración 032) |
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [4.15.1] - 2026-10-30
|
||||||
### Fixed
|
### Fixed
|
||||||
- La 4.15.0 se publicó con `VERSION` en 4.15.0 pero los `package.json` en 4.14.0: el frontend se
|
- La 4.15.0 se publicó con `VERSION` en 4.15.0 pero los `package.json` en 4.14.0: el frontend se
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ autorregistro/aprobacion/SMTP.
|
|||||||
|
|
||||||
## Conexiones externas (v4.12.0, en dev, sin desplegar)
|
## 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
|
- 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`.
|
`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
|
- **Módulo del sistema hecho en dev**: migración 038 aplicada en dev, `modules/integrations`, job
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "4.15.1",
|
"version": "4.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"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 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 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 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) => {
|
export const getRaw = asyncHandler(async (req, res) => {
|
||||||
const { full, file } = await service.getRaw(Number(req.params.id));
|
const { full, file } = await service.getRaw(Number(req.params.id));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { XMLParser } from 'fast-xml-parser';
|
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
|
// 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
|
// `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) => {
|
.map((x) => {
|
||||||
const name = str(x.name);
|
const name = str(x.name);
|
||||||
const category = str(x.system_category, 40);
|
const category = str(x.system_category, 40);
|
||||||
|
const publisher = str(x.publisher, 190);
|
||||||
return {
|
return {
|
||||||
name, version: str(x.version, 120), publisher: str(x.publisher, 190), install_date: str(x.installdate ?? x.install_date, 40),
|
name, version: str(x.version, 120), publisher, install_date: str(x.installdate ?? x.install_date, 40),
|
||||||
category, is_component: isComponent(name, category) ? 1 : 0,
|
category, is_component: isComponent(name, category) || isWindowsBundled(name, publisher, category) ? 1 : 0,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((s) => s.name);
|
.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}%`); }
|
if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
|
||||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT s.name, MAX(s.publisher) AS publisher, COUNT(DISTINCT s.device_id) AS device_count,
|
`SELECT s.name, MAX(s.publisher) AS publisher, MAX(s.category) AS category, MAX(s.is_component) AS is_component,
|
||||||
COUNT(DISTINCT s.version) AS version_count
|
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'
|
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`,
|
${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`,
|
||||||
params
|
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) }));
|
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) {
|
export async function listDevicesWithSoftware(name) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT d.id, d.hostname, d.kind, d.last_user, s.version
|
`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 { fail } from '../../utils/http.js';
|
||||||
import { getAccess as getAccessFor } from './service.js';
|
import { getAccess as getAccessFor } from './service.js';
|
||||||
import {
|
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,
|
getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer, postCpuYear,
|
||||||
} from './controller.js';
|
} 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('/dashboard', canView, getDashboard);
|
||||||
router.get('/software', canView, getSoftware);
|
router.get('/software', canView, getSoftware);
|
||||||
router.get('/software/devices', canView, getSoftwareDevices);
|
router.get('/software/devices', canView, getSoftwareDevices);
|
||||||
|
router.put('/software/classification', requireRole(...CAN_EDIT_DEVICES), putSoftwareClassification);
|
||||||
router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers);
|
router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers);
|
||||||
router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer);
|
router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer);
|
||||||
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer);
|
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 { askStructured } from '../../utils/ai.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { parseInWorker } from './ingest.js';
|
import { parseInWorker } from './ingest.js';
|
||||||
|
import { classifySoftware, LICENSE_TYPES } from './softwareClassifier.js';
|
||||||
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
||||||
|
|
||||||
const STATUSES = ['active', 'retired'];
|
const STATUSES = ['active', 'retired'];
|
||||||
@@ -26,6 +27,8 @@ export async function getDevice(id) {
|
|||||||
const device = await repo.findById(id);
|
const device = await repo.findById(id);
|
||||||
if (!device) throw new HttpError(404, 'Equipo no encontrado');
|
if (!device) throw new HttpError(404, 'Equipo no encontrado');
|
||||||
if (device.extra?.licenses === undefined && device.raw_file) await backfillLicenses(device);
|
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;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +94,23 @@ export async function getRaw(id) {
|
|||||||
return { full, file };
|
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 devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
||||||
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
nombre del SO e `itemtype`. Lo que no tiene columna (impresoras/red SNMP, antivirus, video,
|
||||||
batería) se guarda en `devices.extra_json`.
|
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
|
## Instalar el agente en un equipo
|
||||||
|
|
||||||
1. Superadmin/admin: menú **Equipos → Agentes → Generar token** (se muestra una sola vez).
|
1. Superadmin/admin: menú **Equipos → Agentes → Generar token** (se muestra una sola vez).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.15.1",
|
"version": "4.16.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { Fragment, useMemo, useState } from 'react';
|
||||||
import { MoreVertical, GripVertical } from 'lucide-react';
|
import { MoreVertical, GripVertical, ChevronRight, ChevronDown } from 'lucide-react';
|
||||||
import { useUi } from '../context/ui-context.jsx';
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
import { useDragReorder } from '../hooks/useDragReorder.js';
|
import { useDragReorder } from '../hooks/useDragReorder.js';
|
||||||
import { Flyout, FlyoutItem } from './Flyout.jsx';
|
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
|
// `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
|
// 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.
|
// 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 { t } = useUi();
|
||||||
const [sort, setSort] = useState(initialSort || { key: null, dir: 1 });
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!searchValue) return rows;
|
if (!searchValue) return rows;
|
||||||
const q = searchValue.toLowerCase();
|
const q = searchValue.toLowerCase();
|
||||||
return rows.filter((r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase().includes(q));
|
return rows.filter((r) => haystack(r).includes(q) || getChildren?.(r)?.some((c) => haystack(c).includes(q)));
|
||||||
}, [rows, searchValue, getSearchHaystack]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rows, searchValue, getSearchHaystack, getChildren]);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (!sort.key) return filtered;
|
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 }));
|
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 (
|
return (
|
||||||
<div className="list-card">
|
<div className="list-card">
|
||||||
<table className="list-table">
|
<table className="list-table">
|
||||||
@@ -53,39 +112,20 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{displayRows.map((row) => (
|
{displayRows.map((row) => {
|
||||||
<tr
|
const key = getRowKey(row);
|
||||||
key={getRowKey(row)}
|
const allChildren = getChildren?.(row);
|
||||||
onClick={() => onRowClick?.(row)}
|
const isParent = !!allChildren?.length;
|
||||||
onContextMenu={rowActions ? (event) => { event.preventDefault(); event.currentTarget.querySelector('.row-menu-btn')?.click(); } : undefined}
|
const q = searchValue?.toLowerCase();
|
||||||
tabIndex={onRowClick ? 0 : undefined}
|
const children = isParent && q ? allChildren.filter((c) => haystack(c).includes(q)) : allChildren;
|
||||||
onKeyDown={(event) => { if (onRowClick && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onRowClick(row); } }}
|
const open = isParent && (expanded.has(key) || (!!q && children.length > 0));
|
||||||
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
|
return (
|
||||||
{...(dragEnabled ? getRowProps(row) : {})}
|
<Fragment key={key}>
|
||||||
>
|
{renderRow(row, { isParent, open, onToggle: () => toggleRow(key) })}
|
||||||
{dragEnabled && (
|
{open && children.map((child) => renderRow(child, { child: true }))}
|
||||||
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()} {...getHandleProps(row)}>
|
</Fragment>
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{displayRows.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
|
{displayRows.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
|
||||||
|
|||||||
@@ -294,6 +294,8 @@ export const messages = {
|
|||||||
common_search: 'Buscar...',
|
common_search: 'Buscar...',
|
||||||
common_select: 'Seleccionar',
|
common_select: 'Seleccionar',
|
||||||
common_actions: 'Acciones',
|
common_actions: 'Acciones',
|
||||||
|
common_expand: 'Desplegar',
|
||||||
|
common_collapse: 'Plegar',
|
||||||
common_search_no_results: 'Sin resultados',
|
common_search_no_results: 'Sin resultados',
|
||||||
common_actions: 'Acciones',
|
common_actions: 'Acciones',
|
||||||
common_view: 'Ver',
|
common_view: 'Ver',
|
||||||
@@ -498,6 +500,18 @@ export const messages = {
|
|||||||
eq_after: 'Después',
|
eq_after: 'Después',
|
||||||
eq_history_empty: 'Sin cambios registrados entre reportes.',
|
eq_history_empty: 'Sin cambios registrados entre reportes.',
|
||||||
eq_tab_licenses: 'Licenciamiento',
|
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_windows: 'Windows',
|
||||||
eq_lic_office: 'Office',
|
eq_lic_office: 'Office',
|
||||||
eq_lic_office_installed: 'Office instalado',
|
eq_lic_office_installed: 'Office instalado',
|
||||||
@@ -1076,6 +1090,8 @@ export const messages = {
|
|||||||
common_search: 'Search...',
|
common_search: 'Search...',
|
||||||
common_select: 'Select',
|
common_select: 'Select',
|
||||||
common_actions: 'Actions',
|
common_actions: 'Actions',
|
||||||
|
common_expand: 'Expand',
|
||||||
|
common_collapse: 'Collapse',
|
||||||
common_search_no_results: 'No results',
|
common_search_no_results: 'No results',
|
||||||
common_actions: 'Actions',
|
common_actions: 'Actions',
|
||||||
common_view: 'View',
|
common_view: 'View',
|
||||||
@@ -1280,6 +1296,18 @@ export const messages = {
|
|||||||
eq_after: 'After',
|
eq_after: 'After',
|
||||||
eq_history_empty: 'No changes recorded between reports.',
|
eq_history_empty: 'No changes recorded between reports.',
|
||||||
eq_tab_licenses: 'Licensing',
|
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_windows: 'Windows',
|
||||||
eq_lic_office: 'Office',
|
eq_lic_office: 'Office',
|
||||||
eq_lic_office_installed: 'Installed Office',
|
eq_lic_office_installed: 'Installed Office',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { ListView } from '../../components/ListView.jsx';
|
|||||||
import { Tabs } from '../../components/Tabs.jsx';
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { KpiCard } from '../../components/KpiCard.jsx';
|
import { KpiCard } from '../../components/KpiCard.jsx';
|
||||||
|
import { SoftwareList, SoftwareTypeFilter, matchesSoftwareType } from './SoftwareList.jsx';
|
||||||
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
|
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
|
||||||
|
|
||||||
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
|
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 setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
|
||||||
const [searches, setSearches] = useState({});
|
const [searches, setSearches] = useState({});
|
||||||
const [showComponents, setShowComponents] = useState(false);
|
const [showComponents, setShowComponents] = useState(false);
|
||||||
|
const [softwareType, setSoftwareType] = useState('');
|
||||||
const [fetchingYear, setFetchingYear] = useState(false);
|
const [fetchingYear, setFetchingYear] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
|
// 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 localUsers = only(allLocalUsers, (r) => [r.name, r.sid]);
|
||||||
const localGroups = only(allLocalGroups, (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 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 win = device.extra?.licenses?.windows;
|
||||||
const channel = (oem) => (oem == null ? null : oem ? t('eq_lic_oem') : t('eq_lic_not_oem'));
|
const channel = (oem) => (oem == null ? null : oem ? t('eq_lic_oem') : t('eq_lic_not_oem'));
|
||||||
const winRows = win ? [
|
const winRows = win ? [
|
||||||
@@ -213,10 +215,13 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<div className="toolbar" style={{ marginBottom: 10 }}>
|
<div className="toolbar" style={{ marginBottom: 10 }}>
|
||||||
{tab === 'software' ? (
|
{tab === 'software' ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||||
{t('eq_show_components')}
|
{t('eq_show_components')}
|
||||||
</label>
|
</label>
|
||||||
|
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
|
||||||
|
</div>
|
||||||
) : <div />}
|
) : <div />}
|
||||||
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
|
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
|
||||||
</div>
|
</div>
|
||||||
@@ -304,9 +309,14 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'software' && (
|
{tab === 'software' && (
|
||||||
<SimpleTable empty={emptyText} rows={software} columns={[
|
<SoftwareList
|
||||||
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') }, { key: 'publisher', label: t('eq_publisher') },
|
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' && (
|
{tab === 'licenses' && (
|
||||||
@@ -446,34 +456,37 @@ function DevicesTab({ canEdit }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SoftwareTab() {
|
function SoftwareTab({ canEdit }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [showComponents, setShowComponents] = useState(false);
|
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]);
|
const { data: installs } = useAsync(() => (selected ? listSoftwareDevices(selected) : Promise.resolve([])), [selected]);
|
||||||
|
|
||||||
if (!rows) return <div className="text-muted">{t('common_loading')}</div>;
|
if (!rows) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
|
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
|
||||||
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
|
{ 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') },
|
{ key: 'device_count', label: t('eq_installed_on') },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||||
{t('eq_show_components')}
|
{t('eq_show_components')}
|
||||||
</label>
|
</label>
|
||||||
|
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
|
||||||
|
</div>
|
||||||
<SearchInput value={search} onChange={setSearch} />
|
<SearchInput value={search} onChange={setSearch} />
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<SoftwareList
|
||||||
columns={columns} rows={rows} searchValue={search} getRowKey={(r) => r.name}
|
rows={rows.filter((r) => matchesSoftwareType(r, softwareType))} columns={columns} searchValue={search} canEdit={canEdit}
|
||||||
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''}`} onRowClick={(r) => setSelected(r.name)}
|
onChanged={reload} onRowClick={(r) => setSelected(r.name)} initialSort={{ key: 'device_count', dir: -1 }}
|
||||||
initialSort={{ key: 'device_count', dir: -1 }}
|
|
||||||
/>
|
/>
|
||||||
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
|
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
|
||||||
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
|
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
|
||||||
@@ -674,7 +687,7 @@ export default function EquiposPage() {
|
|||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
{tab === 'summary' && <SummaryTab />}
|
{tab === 'summary' && <SummaryTab />}
|
||||||
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
|
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
|
||||||
{tab === 'software' && <SoftwareTab />}
|
{tab === 'software' && <SoftwareTab canEdit={access.canEdit} />}
|
||||||
{tab === 'agents' && access.canManage && <AgentsTab />}
|
{tab === 'agents' && access.canManage && <AgentsTab />}
|
||||||
{tab === 'share' && access.canManage && <ShareTab />}
|
{tab === 'share' && access.canManage && <ShareTab />}
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export const getDevice = (id) => api.get(`/devices/${id}`);
|
|||||||
export const updateDevice = (id, data) => api.put(`/devices/${id}`, data);
|
export const updateDevice = (id, data) => api.put(`/devices/${id}`, data);
|
||||||
export const getDevicesDashboard = () => api.get('/devices/dashboard');
|
export const getDevicesDashboard = () => api.get('/devices/dashboard');
|
||||||
export const listSoftware = (all) => api.get(`/devices/software${all ? '?all=1' : ''}`);
|
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 listSoftwareDevices = (name) => api.get(`/devices/software/devices?name=${encodeURIComponent(name)}`);
|
||||||
|
|
||||||
export const listInventoryTokens = () => api.get('/devices/tokens');
|
export const listInventoryTokens = () => api.get('/devices/tokens');
|
||||||
|
|||||||
@@ -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 { cursor: pointer; }
|
||||||
.list-table tbody tr:hover { background: rgb(var(--panel)); }
|
.list-table tbody tr:hover { background: rgb(var(--panel)); }
|
||||||
.list-table tbody tr:last-child td { border-bottom: none; }
|
.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)); }
|
.list-empty { padding: 40px 16px; text-align: center; color: rgb(var(--muted)); }
|
||||||
.row-menu-btn { opacity: .6; }
|
.row-menu-btn { opacity: .6; }
|
||||||
.row-menu-btn:hover { opacity: 1; }
|
.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)); }
|
.toast-error { border-left-color: rgb(var(--danger)); }
|
||||||
|
|
||||||
/* --- Flyout / menú contextual --- */
|
/* --- Flyout / menú contextual --- */
|
||||||
.flyout-backdrop { position: fixed; inset: 0; z-index: 90; }
|
.flyout-backdrop { position: fixed; inset: 0; z-index: 260; }
|
||||||
.flyout-panel {
|
.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;
|
border-radius: var(--r-lg); box-shadow: 0 10px 28px rgb(0 0 0 / .35); padding: 6px; min-width: 180px;
|
||||||
}
|
}
|
||||||
.flyout-item {
|
.flyout-item {
|
||||||
|
|||||||
Reference in New Issue
Block a user