diff --git a/CHANGELOG.md b/CHANGELOG.md index 952e8ee..d82b067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog - project.linkedcorp.net +## [4.15.0] - 2026-10-30 +### Added +- Equipos → detalle: pestaña "Licenciamiento" (antes de Historial) con Windows (edición, Product ID, + clave parcial, canal OEM o Retail/Volumen) y Office (licencias que reporta el agente con canal, + Product ID y clave parcial; aviso si es MSDN) más el Office instalado. La clave se guarda solo con + los últimos 5 caracteres. Los equipos ya inventariados se completan desde su último reporte crudo + al abrir el detalle. No indica si la licencia está activada. + ## [4.14.0] - 2026-10-29 ### Added - Equipos → detalle → Hardware: fila "Año del procesador" con botón "Obtener fecha": la IA consulta diff --git a/VERSION b/VERSION index c412a4e..5c517bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.14.0 +4.15.0 diff --git a/backend/src/modules/devices/ingest.js b/backend/src/modules/devices/ingest.js index 913f217..95bc4db 100644 --- a/backend/src/modules/devices/ingest.js +++ b/backend/src/modules/devices/ingest.js @@ -60,7 +60,7 @@ async function saveRaw(deviceid, text, format) { return name; } -function parseInWorker(text, format) { +export function parseInWorker(text, format) { return new Promise((resolve, reject) => { const worker = new Worker(new URL('./parseWorker.js', import.meta.url), { workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 }, diff --git a/backend/src/modules/devices/normalizer.js b/backend/src/modules/devices/normalizer.js index 024d1bf..d1b54e9 100644 --- a/backend/src/modules/devices/normalizer.js +++ b/backend/src/modules/devices/normalizer.js @@ -7,7 +7,7 @@ import { XMLParser } from 'fast-xml-parser'; const XML_ARRAY_SECTIONS = new Set([ 'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers', - 'users', 'local_users', 'local_groups', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus', + 'users', 'local_users', 'local_groups', 'licenseinfos', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus', ]); const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, trimValues: true }); @@ -74,6 +74,38 @@ function isComponent(name, category) { return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || ''); } +// Licenciamiento de Windows y Office. La clave de producto NUNCA se guarda completa en la BD: solo +// el ultimo bloque (la completa sigue solo en el reporte crudo, de descarga restringida). +const maskKey = (v) => { + const key = str(v, 64); + if (!key) return null; + return key.replace(/[A-Z0-9]/gi, '*').slice(0, -5) + key.slice(-5); +}; + +export function extractLicenses(c) { + const hw = c.hardware || {}; + const os = c.operatingsystem || {}; + const winPid = str(hw.winprodid, 64); + const isWindows = /windows/i.test(`${os.name || ''} ${os.full_name || ''} ${hw.osname || ''}`); + const windows = isWindows ? { + edition: str(os.full_name ?? hw.osname), + version: str(os.version ?? hw.osversion, 120), + product_id: winPid, + key: maskKey(hw.winprodkey), + oem: winPid ? /OEM/i.test(winPid) : null, + owner: str(hw.winowner, 190), + company: str(hw.wincompany, 190), + } : null; + const office = asArray(c.licenseinfos) + .filter((x) => /office/i.test(`${x.name || ''} ${x.fullname || ''}`)) + .map((x) => ({ + name: str(x.name), full_name: str(x.fullname), product_id: str(x.productid, 64), key: maskKey(x.key), + oem: x.oem == null || x.oem === '' ? null : String(x.oem) === '1', + msdn: /msdn/i.test(`${x.name || ''} ${x.fullname || ''}`), + })); + return { windows, office }; +} + const LAPTOP_CHASSIS = /laptop|notebook|portable|hand held|sub notebook|convertible|tablet|detachable|netbook/i; const SERVER_CHASSIS = /server|rack|blade|main server|multi-system/i; @@ -197,6 +229,7 @@ export function normalizeInventory(parsed) { for (const key of ['printers', 'network_device', 'antivirus', 'videos', 'batteries', 'sounds']) { if (c[key] && (!Array.isArray(c[key]) || c[key].length)) extra[key] = c[key]; } + extra.licenses = extractLicenses(c); return { device, cpus, memories, disks, volumes, networks, monitors, software, accounts, extra }; } diff --git a/backend/src/modules/devices/repository.js b/backend/src/modules/devices/repository.js index 047f939..6ecf1d1 100644 --- a/backend/src/modules/devices/repository.js +++ b/backend/src/modules/devices/repository.js @@ -229,6 +229,8 @@ export async function updateManual(id, { status, assignedUserId, departmentId, n export const saveCpuYear = (id, year, cpuSummary) => pool.query('UPDATE devices SET cpu_year = ?, cpu_year_for = ? WHERE id = ?', [year, cpuSummary, id]); +export const saveExtra = (id, extra) => pool.query('UPDATE devices SET extra_json = ? WHERE id = ?', [JSON.stringify(extra), id]); + export async function findRawFile(id) { const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]); return row?.raw_file || null; diff --git a/backend/src/modules/devices/service.js b/backend/src/modules/devices/service.js index 089bd85..0c24221 100644 --- a/backend/src/modules/devices/service.js +++ b/backend/src/modules/devices/service.js @@ -8,6 +8,7 @@ import { logAudit } from '../../utils/audit.js'; 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 { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js'; const STATUSES = ['active', 'retired']; @@ -24,9 +25,24 @@ export const listDevices = (filters) => repo.findAll({ 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); return device; } +// Equipos inventariados antes de la pestaña Licenciamiento: se sacan del ultimo reporte crudo una +// sola vez (si falla queda vacio y se completa con el proximo reporte del agente). +async function backfillLicenses(device) { + let licenses = { windows: null, office: [] }; + try { + const full = path.join(env.storageRoot, 'inventories', path.basename(device.raw_file)); + const text = await fs.promises.readFile(full, 'utf8'); + const parsed = await parseInWorker(text, path.extname(full) === '.json' ? 'json' : 'xml'); + licenses = parsed.normalized?.extra?.licenses || licenses; + } catch { /* sin crudo legible: queda vacio */ } + device.extra = { ...(device.extra || {}), licenses }; + await repo.saveExtra(device.id, device.extra); +} + export async function updateDevice(id, data, actor) { const current = await getDevice(id); const status = data.status ?? current.status; diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index ad76bc4..171a9df 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -497,6 +497,21 @@ export const messages = { eq_before: 'Antes', eq_after: 'Después', eq_history_empty: 'Sin cambios registrados entre reportes.', + eq_tab_licenses: 'Licenciamiento', + eq_lic_windows: 'Windows', + eq_lic_office: 'Office', + eq_lic_office_installed: 'Office instalado', + eq_lic_edition: 'Edición', + eq_lic_product_id: 'Product ID', + eq_lic_key: 'Clave (parcial)', + eq_lic_channel: 'Canal', + eq_lic_owner: 'Registrado a', + eq_lic_oem: 'OEM', + eq_lic_not_oem: 'Retail / Volumen', + eq_lic_msdn: 'MSDN: solo desarrollo y pruebas', + eq_lic_no_windows: 'El agente no reportó licencia de Windows (equipo sin Windows o reporte anterior).', + eq_lic_no_office: 'El agente no reportó licencias de Office.', + eq_lic_hint: 'Datos que reporta el agente desde el registro: no indican si la licencia está activada. Microsoft 365 por suscripción se licencia por usuario y no aparece aquí. La clave se muestra parcial.', eq_change_field: 'Dato modificado', eq_change_disk_added: 'Disco agregado', eq_change_disk_removed: 'Disco retirado', @@ -1264,6 +1279,21 @@ export const messages = { eq_before: 'Before', eq_after: 'After', eq_history_empty: 'No changes recorded between reports.', + eq_tab_licenses: 'Licensing', + eq_lic_windows: 'Windows', + eq_lic_office: 'Office', + eq_lic_office_installed: 'Installed Office', + eq_lic_edition: 'Edition', + eq_lic_product_id: 'Product ID', + eq_lic_key: 'Key (partial)', + eq_lic_channel: 'Channel', + eq_lic_owner: 'Registered to', + eq_lic_oem: 'OEM', + eq_lic_not_oem: 'Retail / Volume', + eq_lic_msdn: 'MSDN: development and testing only', + eq_lic_no_windows: 'The agent did not report a Windows license (non-Windows device or older report).', + eq_lic_no_office: 'The agent did not report Office licenses.', + eq_lic_hint: 'Data reported by the agent from the registry: it does not tell whether the license is activated. Microsoft 365 subscriptions are licensed per user and do not show here. The key is shown partially.', eq_change_field: 'Field changed', eq_change_disk_added: 'Disk added', eq_change_disk_removed: 'Disk removed', diff --git a/frontend/src/pages/Equipos/EquiposPage.jsx b/frontend/src/pages/Equipos/EquiposPage.jsx index 49cff07..2ed1d9f 100644 --- a/frontend/src/pages/Equipos/EquiposPage.jsx +++ b/frontend/src/pages/Equipos/EquiposPage.jsx @@ -125,6 +125,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC { key: 'hardware', label: t('eq_tab_hardware') }, { key: 'network', label: t('eq_tab_network'), count: device.networks.length }, { key: 'software', label: t('eq_tab_software'), count: device.software.filter((r) => showComponents || !r.is_component).length }, + { key: 'licenses', label: t('eq_tab_licenses'), count: (device.extra?.licenses?.windows ? 1 : 0) + (device.extra?.licenses?.office?.length || 0) }, { key: 'history', label: t('eq_tab_history'), count: device.changes.length }, ]; @@ -193,6 +194,17 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC 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 win = device.extra?.licenses?.windows; + const channel = (oem) => (oem == null ? null : oem ? t('eq_lic_oem') : t('eq_lic_not_oem')); + const winRows = win ? [ + [t('eq_lic_edition'), val([win.edition, win.version].filter(Boolean).join(' ')), `${win.edition || ''} ${win.version || ''}`], + [t('eq_lic_product_id'), val(win.product_id), win.product_id], + [t('eq_lic_key'), {val(win.key)}, win.key], + [t('eq_lic_channel'), val(channel(win.oem)), channel(win.oem)], + [t('eq_lic_owner'), val([win.owner, win.company].filter(Boolean).join(' · ')), `${win.owner || ''} ${win.company || ''}`], + ] : []; + const officeLicenses = only(device.extra?.licenses?.office || [], (r) => [r.name, r.full_name, r.product_id, r.key]); + const officeInstalled = only(device.software.filter((r) => !r.is_component && /^microsoft (office|365)\b/i.test(r.name)), (r) => [r.name, r.version]); const changes = only(device.changes, (r) => [fmtDate(r.created_at), t(`eq_change_${r.change_type}`), r.field, r.old_value, r.new_value]); return ( @@ -297,6 +309,27 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC ]} /> )} + {tab === 'licenses' && ( + <> +

{t('eq_lic_windows')}

+ {win ? showRows(winRows) :
{t('eq_lic_no_windows')}
} + {!q && officeLicenses.length === 0 ? ( + <>

{t('eq_lic_office')}

{t('eq_lic_no_office')}
+ ) : hwSection(t('eq_lic_office'), officeLicenses, [ + { key: 'name', label: t('common_name'), render: (r) => ( + <>{r.name} {r.msdn && {t('eq_lic_msdn')}} + ) }, + { key: 'full_name', label: t('eq_lic_channel') }, + { key: 'product_id', label: t('eq_lic_product_id') }, + { key: 'key', label: t('eq_lic_key'), render: (r) => {dash(r.key)} }, + ])} + {hwSection(t('eq_lic_office_installed'), officeInstalled, [ + { key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') }, + ])} +
{t('eq_lic_hint')}
+ + )} + {tab === 'history' && ( fmtDate(r.created_at) },