feat: version 4.15.0 -- Equipos: pestaña Licenciamiento (Windows y Office)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [4.14.0] - 2026-10-29
|
||||||
### Added
|
### Added
|
||||||
- Equipos → detalle → Hardware: fila "Año del procesador" con botón "Obtener fecha": la IA consulta
|
- Equipos → detalle → Hardware: fila "Año del procesador" con botón "Obtener fecha": la IA consulta
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async function saveRaw(deviceid, text, format) {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInWorker(text, format) {
|
export function parseInWorker(text, format) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
|
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
|
||||||
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { XMLParser } from 'fast-xml-parser';
|
|||||||
|
|
||||||
const XML_ARRAY_SECTIONS = new Set([
|
const XML_ARRAY_SECTIONS = new Set([
|
||||||
'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers',
|
'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 });
|
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 || '');
|
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 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;
|
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']) {
|
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];
|
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 };
|
return { device, cpus, memories, disks, volumes, networks, monitors, software, accounts, extra };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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) {
|
export async function findRawFile(id) {
|
||||||
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
|
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
|
||||||
return row?.raw_file || null;
|
return row?.raw_file || null;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { logAudit } from '../../utils/audit.js';
|
|||||||
import { encrypt, decrypt } from '../../utils/crypto.js';
|
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 { 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'];
|
||||||
@@ -24,9 +25,24 @@ export const listDevices = (filters) => repo.findAll({
|
|||||||
export async function getDevice(id) {
|
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);
|
||||||
return 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) {
|
export async function updateDevice(id, data, actor) {
|
||||||
const current = await getDevice(id);
|
const current = await getDevice(id);
|
||||||
const status = data.status ?? current.status;
|
const status = data.status ?? current.status;
|
||||||
|
|||||||
@@ -497,6 +497,21 @@ export const messages = {
|
|||||||
eq_before: 'Antes',
|
eq_before: 'Antes',
|
||||||
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_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_field: 'Dato modificado',
|
||||||
eq_change_disk_added: 'Disco agregado',
|
eq_change_disk_added: 'Disco agregado',
|
||||||
eq_change_disk_removed: 'Disco retirado',
|
eq_change_disk_removed: 'Disco retirado',
|
||||||
@@ -1264,6 +1279,21 @@ export const messages = {
|
|||||||
eq_before: 'Before',
|
eq_before: 'Before',
|
||||||
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_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_field: 'Field changed',
|
||||||
eq_change_disk_added: 'Disk added',
|
eq_change_disk_added: 'Disk added',
|
||||||
eq_change_disk_removed: 'Disk removed',
|
eq_change_disk_removed: 'Disk removed',
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
{ key: 'hardware', label: t('eq_tab_hardware') },
|
{ key: 'hardware', label: t('eq_tab_hardware') },
|
||||||
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
|
{ 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: '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 },
|
{ 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 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 = 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'), <code>{val(win.key)}</code>, 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]);
|
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 (
|
return (
|
||||||
@@ -297,6 +309,27 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
]} />
|
]} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'licenses' && (
|
||||||
|
<>
|
||||||
|
<h4>{t('eq_lic_windows')}</h4>
|
||||||
|
{win ? showRows(winRows) : <div className="text-muted">{t('eq_lic_no_windows')}</div>}
|
||||||
|
{!q && officeLicenses.length === 0 ? (
|
||||||
|
<><h4>{t('eq_lic_office')}</h4><div className="text-muted">{t('eq_lic_no_office')}</div></>
|
||||||
|
) : hwSection(t('eq_lic_office'), officeLicenses, [
|
||||||
|
{ key: 'name', label: t('common_name'), render: (r) => (
|
||||||
|
<>{r.name} {r.msdn && <Badge color="warning">{t('eq_lic_msdn')}</Badge>}</>
|
||||||
|
) },
|
||||||
|
{ 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) => <code>{dash(r.key)}</code> },
|
||||||
|
])}
|
||||||
|
{hwSection(t('eq_lic_office_installed'), officeInstalled, [
|
||||||
|
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') },
|
||||||
|
])}
|
||||||
|
<div className="text-muted" style={{ fontSize: '.72rem', marginTop: 12 }}>{t('eq_lic_hint')}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'history' && (
|
{tab === 'history' && (
|
||||||
<SimpleTable empty={q ? emptyText : t('eq_history_empty')} rows={changes} columns={[
|
<SimpleTable empty={q ? emptyText : t('eq_history_empty')} rows={changes} columns={[
|
||||||
{ key: 'created_at', label: t('eq_when'), render: (r) => fmtDate(r.created_at) },
|
{ key: 'created_at', label: t('eq_when'), render: (r) => fmtDate(r.created_at) },
|
||||||
|
|||||||
Reference in New Issue
Block a user