feat: version 4.14.0 -- Equipos: año del procesador con boton Obtener fecha (IA)

La IA consulta solo el nombre del procesador y devuelve el año (el mas antiguo si hay dos).
Migracion 039.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 19:07:31 -05:00
parent 0968e61ced
commit b2c25c4fe5
14 changed files with 89 additions and 6 deletions
+1
View File
@@ -234,6 +234,7 @@ se registra en consola de desarrollo. Antes de usar estas rutas se debe aplicar
- `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).
- `POST /api/devices/:id/cpu-year` → equipo con `cpu_year`: la IA consulta solo el nombre del procesador y devuelve el año de lanzamiento (el más antiguo si hay dos). Roles que editan equipos; `400` sin procesador o IA sin configurar, `422` si no se pudo determinar; 30/hora.
- `GET /api/devices/:id/raw` — último inventario crudo del agente. - `GET /api/devices/:id/raw` — último inventario crudo del agente.
- `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`. - `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`.
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta - `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
+2
View File
@@ -273,3 +273,5 @@ en producción.
## Cambio de correo (migración 035, no aplicada) ## Cambio de correo (migración 035, no aplicada)
`email_change_requests` guarda usuario, correo anterior/nuevo, estado, decisión, motivo y fechas. Los tokens de aprobación y confirmación se guardan solo como hash SHA-256. Índices únicos sobre columnas generadas impiden más de una solicitud abierta por usuario o correo nuevo; el cotejamiento `utf8mb4_unicode_ci` evita duplicados por mayúsculas. `notifications.entity_type` agrega `email_change_request` y conserva `project`, `activity` y `meeting`. El migrador reejecuta todos los archivos; la tabla se crea con `IF NOT EXISTS`. `email_change_requests` guarda usuario, correo anterior/nuevo, estado, decisión, motivo y fechas. Los tokens de aprobación y confirmación se guardan solo como hash SHA-256. Índices únicos sobre columnas generadas impiden más de una solicitud abierta por usuario o correo nuevo; el cotejamiento `utf8mb4_unicode_ci` evita duplicados por mayúsculas. `notifications.entity_type` agrega `email_change_request` y conserva `project`, `activity` y `meeting`. El migrador reejecuta todos los archivos; la tabla se crea con `IF NOT EXISTS`.
- `devices.cpu_year` / `cpu_year_for` (migración `039`): año del procesador consultado a la IA (solo por el nombre del procesador); se ignora si el procesador reportado cambia.
+8
View File
@@ -1,5 +1,13 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [4.14.0] - 2026-10-29
### Added
- Equipos → detalle → Hardware: fila "Año del procesador" con botón "Obtener fecha": la IA consulta
**solo el nombre del procesador** (nada del equipo ni del usuario) y devuelve el año de
lanzamiento; si hay dos fechas, el más antiguo. Se valida el rango antes de guardar, queda en
auditoría y en el consumo de IA, y el año se descarta si el equipo cambia de procesador. Solo
roles que editan equipos; 30 consultas/hora. Migración `039` (`devices.cpu_year`, `cpu_year_for`).
## [4.13.1] - 2026-10-29 ## [4.13.1] - 2026-10-29
### Changed ### Changed
- Equipos → listado: nueva columna "Asignado a" después de Nombre (usuario asignado a mano; "—" si no hay). - Equipos → listado: nueva columna "Asignado a" después de Nombre (usuario asignado a mano; "—" si no hay).
+1 -1
View File
@@ -1 +1 @@
4.13.1 4.14.0
@@ -0,0 +1,6 @@
-- Año de lanzamiento del procesador (el agente GLPI no lo reporta): lo consulta la IA solo con el
-- nombre del procesador. cpu_year_for guarda el cpu_summary usado, para descartar el año si el
-- procesador del equipo cambia. Idempotente.
ALTER TABLE devices
ADD COLUMN IF NOT EXISTS cpu_year SMALLINT UNSIGNED NULL,
ADD COLUMN IF NOT EXISTS cpu_year_for VARCHAR(255) NULL;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.13.1", "version": "4.14.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
@@ -23,3 +23,5 @@ export const getAccess = asyncHandler(async (req, res) => ok(res, await service.
export const getViewers = asyncHandler(async (req, res) => ok(res, await service.listViewers())); export const getViewers = asyncHandler(async (req, res) => ok(res, await service.listViewers()));
export const postViewer = asyncHandler(async (req, res) => ok(res, await service.addViewer(Number(req.body.userId), req.currentUser), 201)); export const postViewer = asyncHandler(async (req, res) => ok(res, await service.addViewer(Number(req.body.userId), req.currentUser), 201));
export const deleteViewer = asyncHandler(async (req, res) => ok(res, await service.removeViewer(Number(req.params.userId), req.currentUser))); export const deleteViewer = asyncHandler(async (req, res) => ok(res, await service.removeViewer(Number(req.params.userId), req.currentUser)));
export const postCpuYear = asyncHandler(async (req, res) => ok(res, await service.fetchCpuYear(Number(req.params.id), req.currentUser)));
@@ -214,6 +214,8 @@ export async function findById(id) {
[id] [id]
); );
detail.changes = changes; detail.changes = changes;
// El año del procesador solo vale mientras el equipo siga reportando el mismo procesador.
if (detail.cpu_year_for !== detail.cpu_summary) detail.cpu_year = null;
return detail; return detail;
} }
@@ -225,6 +227,8 @@ export async function updateManual(id, { status, assignedUserId, departmentId, n
return result.affectedRows > 0; return result.affectedRows > 0;
} }
export const saveCpuYear = (id, year, cpuSummary) => pool.query('UPDATE devices SET cpu_year = ?, cpu_year_for = ? WHERE id = ?', [year, cpuSummary, 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;
+6 -1
View File
@@ -1,15 +1,19 @@
import { Router } from 'express'; import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js'; import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js'; 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, getRaw,
getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer, getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer, postCpuYear,
} from './controller.js'; } from './controller.js';
const router = Router(); const router = Router();
// Cada consulta a la IA cuesta: tope por IP para no gastar saldo por error o abuso.
const aiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false });
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo // Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers). // admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
@@ -38,6 +42,7 @@ router.delete('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), deleteToken);
router.get('/', canView, getDevices); router.get('/', canView, getDevices);
router.get('/:id', canView, getDevice); router.get('/:id', canView, getDevice);
router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw); router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw);
router.post('/:id/cpu-year', requireRole(...CAN_EDIT_DEVICES), aiLimiter, postCpuYear);
router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice); router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice);
export default router; export default router;
+25
View File
@@ -6,6 +6,7 @@ import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js'; 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 * as repo from './repository.js'; import * as repo from './repository.js';
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js'; import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
@@ -42,6 +43,30 @@ export async function updateDevice(id, data, actor) {
return getDevice(id); return getDevice(id);
} }
// Año de lanzamiento del procesador, consultado a la IA SOLO con el nombre del procesador (nada
// del equipo, usuario ni red). Si la IA da más de una fecha se pide la más antigua. La IA solo
// sugiere: se valida el rango antes de guardar.
export async function fetchCpuYear(id, actor) {
const device = await getDevice(id);
const cpuName = device.cpus.find((c) => c.name)?.name;
if (!cpuName) throw new HttpError(400, 'Este equipo no reporta procesador');
const result = await askStructured({
system: 'Eres un experto en hardware de computadores. Te dan SOLO el nombre de un procesador. Indica el AÑO en que se lanzó. Si hay más de una fecha asociada (por ejemplo anuncio y disponibilidad), devuelve el año MÁS ANTIGUO. Si no puedes determinarlo con seguridad, devuelve null: no inventes.',
prompt: `Procesador: ${String(cpuName).trim().slice(0, 200)}`,
toolName: 'cpu_release_year',
schema: { type: 'object', properties: { year: { type: ['integer', 'null'], description: 'Año de lanzamiento (4 dígitos) o null' } }, required: ['year'] },
maxTokens: 200,
usage: { userId: actor.id, action: 'device_cpu_year' },
});
const year = Number(result?.year);
if (!Number.isInteger(year) || year < 1990 || year > new Date().getFullYear() + 1) {
throw new HttpError(422, 'No se pudo determinar el año de este procesador');
}
await repo.saveCpuYear(id, year, device.cpu_summary);
await logAudit({ userId: actor.id, action: 'cpu_year', entityType: 'device', entityId: id, detail: { cpu: cpuName, year } });
return getDevice(id);
}
export async function getRaw(id) { export async function getRaw(id) {
const file = await repo.findRawFile(id); const file = await repo.findRawFile(id);
if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo'); if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo');
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.13.1", "version": "4.14.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+8
View File
@@ -466,6 +466,10 @@ export const messages = {
eq_assigned_to: 'Asignado a', eq_assigned_to: 'Asignado a',
eq_notes: 'Notas', eq_notes: 'Notas',
eq_cpu: 'Procesador', eq_cpu: 'Procesador',
eq_cpu_year: 'Año del procesador',
eq_cpu_year_get: 'Obtener fecha',
eq_cpu_year_loading: 'Consultando...',
eq_cpu_year_hint: 'La IA consulta solo el nombre del procesador y devuelve el año de lanzamiento (el más antiguo si hay dos).',
eq_ram: 'Memoria RAM', eq_ram: 'Memoria RAM',
eq_disk_total: 'Almacenamiento', eq_disk_total: 'Almacenamiento',
eq_disk_max_used: 'volumen más lleno', eq_disk_max_used: 'volumen más lleno',
@@ -1229,6 +1233,10 @@ export const messages = {
eq_assigned_to: 'Assigned to', eq_assigned_to: 'Assigned to',
eq_notes: 'Notes', eq_notes: 'Notes',
eq_cpu: 'Processor', eq_cpu: 'Processor',
eq_cpu_year: 'Processor year',
eq_cpu_year_get: 'Get date',
eq_cpu_year_loading: 'Looking up...',
eq_cpu_year_hint: 'The AI looks up only the processor name and returns its release year (the oldest if there are two).',
eq_ram: 'RAM', eq_ram: 'RAM',
eq_disk_total: 'Storage', eq_disk_total: 'Storage',
eq_disk_max_used: 'fullest volume', eq_disk_max_used: 'fullest volume',
+23 -2
View File
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react'; import { Pencil, Save, Plus, Trash2, Power, Copy, Download, Sparkles } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx'; import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx'; import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js'; import { useAsync, useAction } from '../../hooks/useAsync.js';
import { import {
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices, listDevices, getDevice, updateDevice, fetchDeviceCpuYear, getDevicesDashboard, listSoftware, listSoftwareDevices,
getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer, getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer,
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret, listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
} from '../../services/devices.js'; } from '../../services/devices.js';
@@ -69,6 +69,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 [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).
const draft = useDraftForm({ const draft = useDraftForm({
@@ -84,6 +85,16 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name })); const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name })); const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
// Año de lanzamiento del procesador: la IA lo consulta solo con el nombre del procesador.
function getCpuYear() {
setFetchingYear(true);
runAction(async () => {
await fetchDeviceCpuYear(id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}).catch(() => {}).finally(() => setFetchingYear(false));
}
function startEdit() { function startEdit() {
setEditing(true); setEditing(true);
} }
@@ -147,6 +158,16 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
const cpuText = device.cpus.map((c) => `${c.name || ''}${c.cores ? ` · ${c.cores}c/${c.threads || '?'}t` : ''}`).join(' | '); const cpuText = device.cpus.map((c) => `${c.name || ''}${c.cores ? ` · ${c.cores}c/${c.threads || '?'}t` : ''}`).join(' | ');
const hwRows = [ const hwRows = [
[t('eq_cpu'), val(cpuText), cpuText], [t('eq_cpu'), val(cpuText), cpuText],
[t('eq_cpu_year'), (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{val(device.cpu_year)}
{canEdit && cpuText && (
<Button size="sm" variant="standard" icon={Sparkles} onClick={getCpuYear} disabled={fetchingYear} title={t('eq_cpu_year_hint')}>
{fetchingYear ? t('eq_cpu_year_loading') : t('eq_cpu_year_get')}
</Button>
)}
</span>
), String(device.cpu_year ?? '')],
[t('eq_ram'), gb(device.ram_mb), gb(device.ram_mb)], [t('eq_ram'), gb(device.ram_mb), gb(device.ram_mb)],
[t('eq_disk_total'), <>{gb(device.disk_total_mb)} {device.max_disk_used_pct != null && <span className="text-muted">· {t('eq_disk_max_used')} {device.max_disk_used_pct}%</span>}</>, `${gb(device.disk_total_mb)} ${device.max_disk_used_pct ?? ''}`], [t('eq_disk_total'), <>{gb(device.disk_total_mb)} {device.max_disk_used_pct != null && <span className="text-muted">· {t('eq_disk_max_used')} {device.max_disk_used_pct}%</span>}</>, `${gb(device.disk_total_mb)} ${device.max_disk_used_pct ?? ''}`],
]; ];
+1
View File
@@ -20,3 +20,4 @@ export const getDevicesAccess = () => api.get('/devices/access');
export const listDeviceViewers = () => api.get('/devices/viewers'); export const listDeviceViewers = () => api.get('/devices/viewers');
export const addDeviceViewer = (userId) => api.post('/devices/viewers', { userId }); export const addDeviceViewer = (userId) => api.post('/devices/viewers', { userId });
export const removeDeviceViewer = (userId) => api.delete(`/devices/viewers/${userId}`); export const removeDeviceViewer = (userId) => api.delete(`/devices/viewers/${userId}`);
export const fetchDeviceCpuYear = (id) => api.post(`/devices/${id}/cpu-year`);