feat: version 2.15.0 -- menu Equipos (inventario de TI con agente GLPI)

Ingesta POST /api/inventory (JSON/XML, token de instalacion), upsert por deviceid,
historial de cambios, pestañas Resumen/Equipos/Software/Agentes, detalle con buscador
por pestaña y casilla para ocultar actualizaciones/drivers/componentes del software.
Migraciones 023 y 024. Diseño en docs/ARQUITECTURA-INVENTARIO.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-19 19:05:00 -05:00
parent c33306a881
commit e25c2c3b35
27 changed files with 1730 additions and 11 deletions
+4 -1
View File
@@ -79,6 +79,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
- Calendario y Gantt de solo lectura
- Comentarios y adjuntos
- Notificaciones in-app
- Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`)
- Configuración (catálogos: estados, prioridades, tipos de proyecto)
## Restricciones tecnicas
@@ -125,6 +126,8 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
- SQL siempre parametrizado. Uploads: validar MIME/tamaño, ruta de archivo generada por el servidor.
- `forgot-password` responde SIEMPRE genérico (exista o no el correo); el envío de correo nunca
debe poder tumbar ni cambiar la respuesta de un endpoint (`sendMail` atrapa sus propios errores).
- Ingesta de Equipos (`POST /api/inventory`): sin JWT, autenticada por token de instalación
(`inventory_tokens`, solo hash); cuerpo crudo con límite de tamaño e inflado.
- Sin SMTP configurado, el sistema degrada a modo dev: los correos de verificación/recuperación se
loguean en la consola del backend en vez de enviarse (nunca rompe el flujo).
@@ -160,7 +163,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
- `README.md`, `SPEC.md`, `BASE_DE_DATOS.md`, `CONFIGURACION.md`, `API.md`, `VERSIONADO.md`
- `PRODUCCION.md`, `DEPLOY_WEB.md` (topología y flujo de deploy — arquetipo B, en producción)
- `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md`
- `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md`, `docs/ARQUITECTURA-INVENTARIO.md`
- `ESTADO.md` (handoff — leer siempre al retomar el proyecto)
## Pendientes para quedar listo
+14
View File
@@ -119,6 +119,20 @@
- Siempre privadas: una nota de otro usuario responde `404` (no `403`, para no revelar que
existe). Sin restricción de rol — cualquier autenticado tiene las suyas.
### Equipos (`/api/devices`) — superadmin / admin / it_manager (tokens: solo superadmin / admin)
- `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 }`.
- `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/:id/raw` — último inventario crudo del agente.
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
(el token en claro solo se devuelve en el `POST`).
### Ingesta del agente GLPI (sin JWT; token de ingesta)
- `POST /api/inventory` (alias `POST /front/inventory.php`) — cuerpo JSON/XML, opcionalmente
comprimido (gzip/zlib). Auth: password Basic / `Bearer` / `X-Ingest-Token` / `?token=`.
`401` sin token válido, `400` formato inválido. Responde `{"status":"ok"}` (o `<REPLY/>` en XML).
### Notificaciones (`/api/notifications`)
- `GET /api/notifications` · `PATCH /api/notifications/:id/read` · `PATCH /api/notifications/read-all`
+10
View File
@@ -42,6 +42,10 @@
| `notes` | apuntes rápidos personales, menú propio "Notas"; editable solo por el dueño |
| `note_shares` | con quién está compartida (solo lectura) cada nota — `note_id, user_id` |
| `vault_entries` | repositorio de credenciales privado por usuario (menú "Contraseñas"), contraseña/notas cifradas |
| `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_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) |
| `inventory_tokens` | tokens de ingesta del agente (solo hash sha256) |
## Relaciones clave
@@ -202,3 +206,9 @@ Método: archivos SQL idempotentes en `backend/database/migrations/00X-nombre.sq
(`CREATE TABLE IF NOT EXISTS` / `information_schema` guards), aplicados con
`node src/scripts/migrate-db.js`. Siempre backup (`mysqldump --single-transaction`) antes de migrar
en producción.
- Equipos (desde v2.15.0, migración `023_devices.sql`): `devices.deviceid` UNIQUE; `assigned_user_id`
y `department_id` son datos manuales (el agente nunca los toca). Hijos con `ON DELETE CASCADE`.
`device_software.is_component` (migración `024`) marca actualizaciones/runtimes/drivers/paquetes del
sistema (según `system_category` del agente + nombre, ver `normalizer.js#isComponent`); las vistas de
software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda solo el hash; el token en claro se muestra una vez al crearlo.
+13
View File
@@ -1,5 +1,18 @@
# Changelog - project.linkedcorp.net
## [2.15.0] - 2026-10-19
### Added
- Menú **Equipos**: inventario de TI alimentado por el agente GLPI oficial (sin servidor GLPI).
Ingesta `POST /api/inventory` (JSON/XML, gzip/zlib) con token de instalación, upsert por
`deviceid`, historial de cambios, KPIs, software transversal y pestaña Agentes para generar
tokens. Edición manual de estado, usuario asignado, departamento y notas.
- Migración `023_devices.sql` (devices + componentes, device_changes, inventory_tokens).
- Equipos → Software (general y por equipo): casilla "Mostrar actualizaciones, drivers y componentes
del sistema", desmarcada por defecto (solo aplicaciones). Migración `024` agrega `category` e
`is_component` a `device_software`; se decide por `system_category` del agente y por nombre.
- Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en
`docs/ARQUITECTURA-INVENTARIO.md`.
## [2.14.0] - 2026-10-14
### Added
- Contraseñas: "Compartir grupo" en el menú de un grupo — comparte de una vez todas sus
+5
View File
@@ -89,3 +89,8 @@ export default defineConfig({
- Backend responde en `4019` (`curl http://localhost:4019/health`).
- Conexión a base válida (`node -e "require('./src/config/db')" ` o el log de arranque del servidor).
- `.env.example` alineado con el código (nuevas variables se agregan también ahí).
## Equipos (inventario)
- `INVENTORY_MAX_BYTES` (opcional, default 15 MB): tope del cuerpo de `POST /api/inventory`.
- Los inventarios crudos se guardan en `STORAGE_ROOT/inventories/` (un archivo por equipo).
+12 -1
View File
@@ -1,6 +1,6 @@
# ESTADO — project.linkedcorp.net
Ultima actualizacion: 2026-09-06
Ultima actualizacion: 2026-10-19 (v2.15.0 en desarrollo, sin desplegar)
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP.
@@ -20,6 +20,17 @@ autorregistro/aprobacion/SMTP.
## Hecho y verificado
### Menú Equipos (v2.15.0, 2026-10-19) — EN DESARROLLO, NO DESPLEGADO
- Inventario de TI con agente GLPI oficial; diseño traído de linkedtrack (que sigue aparte, sin
tocar): `docs/ARQUITECTURA-INVENTARIO.md`. Backend `modules/devices/` (ingest, normalizer,
repository, service, routes), migración `023` aplicada en dev, frontend `pages/Equipos/`.
- Verificado con inventarios sintéticos JSON y XML (alta, actualización, diff, 401 sin token,
handshake `contact`). **Pendiente: probar con un agente GLPI real** (ver "Pendientes" del doc de
arquitectura) y ajustar `normalizer.js`/handshake según el JSON real.
- Antes de producción: migración 023 en `.32`, `npm install` (fast-xml-parser), confirmar que NPM
deja pasar `POST /api/inventory` desde la red de los equipos y subir versión/deploy.
### Fase 1 — MVP en desarrollo (2026-09-03)
- Backend completo: auth (JWT + rate limit), users, departments, catalogs, projects (+members),
+2
View File
@@ -39,6 +39,7 @@ project.linkedcorp.net/
- Proyectos, Actividades y Tareas
- Dashboard, Calendario, Gantt (solo lectura)
- Comentarios, adjuntos, notificaciones in-app
- Equipos (inventario de TI con agentes GLPI)
- Configuración (catálogos)
## Arranque rapido
@@ -76,5 +77,6 @@ la misma red.
- `API.md` — contrato de la API.
- `BASE_DE_DATOS.md` — modelo de datos.
- `CONFIGURACION.md` — variables de entorno y setup local.
- `docs/ARQUITECTURA-INVENTARIO.md` — módulo Equipos (agente GLPI + colector).
- `docs/UI-UX-WINDOWS.md` — identidad visual del proyecto (Fluent/Windows 11).
- `ESTADO.md` — punto de retoma del proyecto (handoff).
+14 -2
View File
@@ -77,7 +77,19 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
- In-app (campana con contador): asignación de actividad, actividad vencida/por vencer (job diario),
cambio de estado relevante.
### 11. Configuración
### 11. Equipos (inventario TI, desde v2.15.0)
- Menú "Equipos" visible para `superadmin`, `admin` y `it_manager`. Inventario automático de
hardware y software alimentado por el **agente GLPI oficial** instalado en cada equipo (sin
servidor GLPI). Diseño en `docs/ARQUITECTURA-INVENTARIO.md`.
- Pestañas: Equipos (KPIs + listado + detalle General/Hardware/Red/Software/Historial),
Software (transversal: en cuántos equipos está instalado) y Agentes (tokens de ingesta,
solo `superadmin`/`admin`).
- Edición manual solo de: estado (activo/de baja), usuario asignado, departamento y notas; el
resto lo manda el agente. Historial de cambios entre reportes.
- Fuera de este módulo (fase 2): tickets/helpdesk, despliegue remoto de software, panel de
impresoras con tóner, export CSV.
### 12. Configuración
- Catálogos editables por el Administrador: estados de proyecto, estados de actividad, prioridades,
tipos de proyecto, departamentos. Vienen con datos semilla — no requieren configuración inicial.
- Preferencias de interfaz: idioma es-EC/en-US, tema claro/oscuro, apariencia de botones.
@@ -115,7 +127,7 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
- Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada.
- Permisos configurables por módulo × acción desde la UI.
- Notificaciones por email/WhatsApp.
- Equipos de trabajo como entidad propia multi-proyecto (hoy: miembros por proyecto).
- Equipos de trabajo (personas) como entidad propia multi-proyecto (hoy: miembros por proyecto).
## Estado para produccion
+1 -1
View File
@@ -1 +1 @@
2.14.0
2.15.0
+155
View File
@@ -0,0 +1,155 @@
-- Modulo Equipos (inventario alimentado por el agente GLPI). Idempotente: CREATE TABLE IF NOT EXISTS.
-- Un equipo = una fila en `devices`, upsert por `deviceid` (identificador estable que genera el
-- agente; el hostname cambia). Los componentes se reemplazan completos en cada reporte.
SET NAMES utf8mb4;
-- Tokens que autentican al agente en la ingesta. Se guarda el HASH (sha256), nunca el token.
CREATE TABLE IF NOT EXISTS inventory_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(120) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_used_at DATETIME NULL,
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inventory_tokens_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS devices (
id INT AUTO_INCREMENT PRIMARY KEY,
deviceid VARCHAR(255) NOT NULL UNIQUE,
hostname VARCHAR(190) NULL,
kind ENUM('desktop','laptop','server','virtual','printer','network','other') NOT NULL DEFAULT 'other',
manufacturer VARCHAR(160) NULL,
model VARCHAR(190) NULL,
serial VARCHAR(160) NULL,
os_name VARCHAR(190) NULL,
os_version VARCHAR(120) NULL,
os_arch VARCHAR(40) NULL,
domain VARCHAR(190) NULL,
last_user VARCHAR(190) NULL,
ip_main VARCHAR(64) NULL,
mac_main VARCHAR(40) NULL,
cpu_summary VARCHAR(255) NULL,
ram_mb INT NULL,
disk_total_mb BIGINT NULL,
disk_free_mb BIGINT NULL,
max_disk_used_pct TINYINT UNSIGNED NULL,
agent_version VARCHAR(80) NULL,
tag VARCHAR(120) NULL,
itemtype VARCHAR(60) NULL,
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Baja logica (conserva historial); un nuevo reporte NO reactiva un equipo dado de baja.
status ENUM('active','retired') NOT NULL DEFAULT 'active',
assigned_user_id INT NULL,
department_id INT NULL,
notes TEXT NULL,
raw_file VARCHAR(255) NULL,
extra_json LONGTEXT NULL,
KEY idx_devices_hostname (hostname),
KEY idx_devices_last_seen (last_seen),
CONSTRAINT fk_devices_user FOREIGN KEY (assigned_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_devices_department FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_cpus (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id INT NOT NULL,
name VARCHAR(255) NULL,
manufacturer VARCHAR(120) NULL,
cores INT NULL,
threads INT NULL,
speed_mhz INT NULL,
KEY idx_device_cpus_device (device_id),
CONSTRAINT fk_device_cpus_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_memories (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id INT NOT NULL,
slot VARCHAR(80) NULL,
capacity_mb INT NULL,
type VARCHAR(60) NULL,
speed VARCHAR(40) NULL,
manufacturer VARCHAR(120) NULL,
serial VARCHAR(120) NULL,
KEY idx_device_memories_device (device_id),
CONSTRAINT fk_device_memories_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_disks (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id INT NOT NULL,
name VARCHAR(190) NULL,
model VARCHAR(190) NULL,
manufacturer VARCHAR(120) NULL,
type VARCHAR(60) NULL,
interface VARCHAR(60) NULL,
size_mb BIGINT NULL,
serial VARCHAR(160) NULL,
KEY idx_device_disks_device (device_id),
CONSTRAINT fk_device_disks_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_volumes (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id INT NOT NULL,
mount VARCHAR(190) NULL,
label VARCHAR(190) NULL,
filesystem VARCHAR(60) NULL,
type VARCHAR(60) NULL,
total_mb BIGINT NULL,
free_mb BIGINT NULL,
KEY idx_device_volumes_device (device_id),
CONSTRAINT fk_device_volumes_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_networks (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id INT NOT NULL,
description VARCHAR(255) NULL,
mac VARCHAR(40) NULL,
ip VARCHAR(64) NULL,
mask VARCHAR(64) NULL,
gateway VARCHAR(64) NULL,
type VARCHAR(60) NULL,
status VARCHAR(40) NULL,
KEY idx_device_networks_device (device_id),
CONSTRAINT fk_device_networks_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_monitors (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id INT NOT NULL,
manufacturer VARCHAR(120) NULL,
model VARCHAR(190) NULL,
serial VARCHAR(120) NULL,
KEY idx_device_monitors_device (device_id),
CONSTRAINT fk_device_monitors_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS device_software (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
version VARCHAR(120) NULL,
publisher VARCHAR(190) NULL,
install_date VARCHAR(40) NULL,
KEY idx_device_software_device (device_id),
KEY idx_device_software_name (name(120)),
CONSTRAINT fk_device_software_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Diff entre reportes consecutivos (RAM que bajo, disco nuevo, software instalado/desinstalado).
CREATE TABLE IF NOT EXISTS device_changes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
device_id INT NOT NULL,
change_type VARCHAR(40) NOT NULL,
field VARCHAR(120) NULL,
old_value TEXT NULL,
new_value TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_device_changes_device (device_id, created_at),
CONSTRAINT fk_device_changes_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,6 @@
-- Software de equipos: categoria que reporta el agente (application, store, system_component, update...)
-- y bandera `is_component` (actualizaciones, runtimes, drivers, paquetes del sistema) para ocultarlos
-- por defecto en las vistas de software. Idempotente.
ALTER TABLE device_software
ADD COLUMN IF NOT EXISTS category VARCHAR(40) NULL,
ADD COLUMN IF NOT EXISTS is_component TINYINT(1) NOT NULL DEFAULT 0;
+33 -2
View File
@@ -1,18 +1,19 @@
{
"name": "project-linkedcorp-backend",
"version": "1.0.0",
"version": "2.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-backend",
"version": "1.0.0",
"version": "2.14.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"fast-xml-parser": "^4.5.7",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.3.0",
@@ -407,6 +408,24 @@
"express": ">= 4.11"
}
},
"node_modules/fast-xml-parser": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz",
"integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1186,6 +1205,18 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "2.14.0",
"version": "2.15.0",
"private": true,
"type": "module",
"main": "src/server.js",
@@ -17,6 +17,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"fast-xml-parser": "^4.5.7",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.3.0",
+5
View File
@@ -24,6 +24,8 @@ import updaterRoutes from './modules/updater/routes.js';
import errorLogsRoutes from './modules/errorLogs/routes.js';
import notesRoutes from './modules/notes/routes.js';
import vaultRoutes from './modules/vault/routes.js';
import devicesRoutes from './modules/devices/routes.js';
import inventoryIngestRoutes from './modules/devices/ingest.js';
export const app = express();
@@ -34,6 +36,8 @@ app.set('trust proxy', 1);
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors({ origin: env.corsOrigin, credentials: true }));
// Ingesta del agente GLPI: ANTES del parser JSON (usa cuerpo crudo, sin CORS: la llama el agente).
app.use(inventoryIngestRoutes);
app.use(express.json({ limit: '2mb' }));
app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null }));
@@ -57,6 +61,7 @@ app.use('/api/updater', updaterRoutes);
app.use('/api/error-logs', errorLogsRoutes);
app.use('/api/notes', notesRoutes);
app.use('/api/vault', vaultRoutes);
app.use('/api/devices', devicesRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
+19
View File
@@ -0,0 +1,19 @@
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getDevices = asyncHandler(async (req, res) => ok(res, await service.listDevices(req.query)));
export const getDevice = asyncHandler(async (req, res) => ok(res, await service.getDevice(Number(req.params.id))));
export const putDevice = asyncHandler(async (req, res) => ok(res, await service.updateDevice(Number(req.params.id), req.body, req.currentUser)));
export const getDashboard = asyncHandler(async (req, res) => ok(res, await service.getDashboard()));
export const getSoftware = asyncHandler(async (req, res) => ok(res, await service.listSoftware(req.query.q, req.query.all)));
export const getSoftwareDevices = asyncHandler(async (req, res) => ok(res, await service.devicesWithSoftware(String(req.query.name || ''))));
export const getRaw = asyncHandler(async (req, res) => {
const { full, file } = await service.getRaw(Number(req.params.id));
res.download(full, file);
});
export const getTokens = asyncHandler(async (req, res) => ok(res, await service.listTokens()));
export const postToken = asyncHandler(async (req, res) => ok(res, await service.createToken(req.body.label, req.currentUser), 201));
export const patchToken = asyncHandler(async (req, res) => ok(res, await service.setTokenActive(Number(req.params.id), !!req.body.isActive, req.currentUser)));
export const deleteToken = asyncHandler(async (req, res) => ok(res, await service.removeToken(Number(req.params.id), req.currentUser)));
+124
View File
@@ -0,0 +1,124 @@
import crypto from 'node:crypto';
import zlib from 'node:zlib';
import fs from 'node:fs';
import path from 'node:path';
import express from 'express';
import { env } from '../../config/env.js';
import { HttpError, asyncHandler } from '../../utils/http.js';
import * as repo from './repository.js';
import { parseInventoryText, normalizeInventory } from './normalizer.js';
// Ingesta del agente GLPI. NO usa JWT de usuario: cada instalacion lleva un token (guardado como
// hash en inventory_tokens) que el agente manda como password Basic, Bearer, X-Ingest-Token o
// ?token=. Va montada ANTES de express.json (cuerpo crudo) en app.js.
const MAX_BYTES = Number(process.env.INVENTORY_MAX_BYTES || 15 * 1024 * 1024);
const MAX_INFLATED = MAX_BYTES * 4; // tope de inflado (anti zip-bomb)
const REPORT_EVERY_HOURS = 24;
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
function extractToken(req) {
if (req.headers['x-ingest-token']) return String(req.headers['x-ingest-token']).trim();
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ')) return auth.slice(7).trim();
if (auth.startsWith('Basic ')) {
try {
const dec = Buffer.from(auth.slice(6), 'base64').toString('utf8');
return dec.includes(':') ? dec.slice(dec.indexOf(':') + 1) : dec;
} catch { /* token invalido: cae abajo */ }
}
if (req.query?.token) return String(req.query.token).trim();
return null;
}
function inflate(buf) {
const opts = { maxOutputLength: MAX_INFLATED };
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return zlib.gunzipSync(buf, opts);
if (buf.length > 2 && buf[0] === 0x78) return zlib.inflateSync(buf, opts);
return buf;
}
function detectFormat(text) {
const t = text.trimStart();
if (t.startsWith('{') || t.startsWith('[')) return 'json';
if (t.startsWith('<')) return 'xml';
return null;
}
function saveRaw(deviceid, text, format) {
const dir = path.join(env.storageRoot, 'inventories');
fs.mkdirSync(dir, { recursive: true });
// Un archivo por equipo (se sobrescribe): solo interesa el ultimo reporte crudo para depurar.
const name = `${sha256(deviceid).slice(0, 24)}.${format}`;
fs.writeFileSync(path.join(dir, name), text);
return name;
}
const ok = (res, format) => (format === 'xml'
? res.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><REPLY></REPLY>')
: res.json({ status: 'ok' }));
const receive = asyncHandler(async (req, res) => {
const token = extractToken(req);
const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null;
// Log de diagnostico de TODA peticion del agente (sin imprimir el token): util para depurar la conexion.
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`);
if (!tokenRow) {
// El agente (LWP) manda las credenciales solo despues de recibir este desafio Basic.
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
throw new HttpError(401, 'No autorizado');
}
repo.touchToken(tokenRow.id).catch(() => {});
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (!body.length) throw new HttpError(400, 'Cuerpo vacio');
let text;
try {
text = inflate(body).toString('utf8');
} catch {
throw new HttpError(400, 'Cuerpo comprimido invalido o demasiado grande');
}
const format = detectFormat(text);
if (!format) throw new HttpError(400, 'Formato no reconocido');
let parsed;
try {
parsed = parseInventoryText(text, format);
} catch {
throw new HttpError(400, 'No se pudo interpretar el inventario');
}
// Handshake del agente antes de enviar el inventario: se contesta sin guardar nada.
if (parsed.action === 'contact' || parsed.action === 'prolog') {
if (format === 'xml') {
return res.type('application/xml').send(
`<?xml version="1.0" encoding="UTF-8"?><REPLY><RESPONSE>SEND</RESPONSE><PROLOG_FREQ>${REPORT_EVERY_HOURS}</PROLOG_FREQ></REPLY>`
);
}
return res.json({ status: 'ok', expiration: String(REPORT_EVERY_HOURS) });
}
if (parsed.action && parsed.action !== 'inventory') return ok(res, format);
if (!parsed.deviceid) throw new HttpError(400, 'Inventario sin deviceid');
const normalized = normalizeInventory(parsed);
const rawFile = saveRaw(parsed.deviceid, text, format);
const result = await repo.upsertInventory(parsed.deviceid, normalized, rawFile);
console.log(`[inventario] ${result.created ? 'nuevo' : 'actualizado'} #${result.id} ${normalized.device.hostname || parsed.deviceid} (${format}, ${result.changeCount} cambios)`);
return ok(res, format);
});
const router = express.Router();
const rawBody = express.raw({ type: () => true, limit: MAX_BYTES });
// Ruta propia + alias de la ruta nativa de GLPI (para no parchear el agente).
router.post('/api/inventory', rawBody, receive);
router.post('/front/inventory.php', rawBody, receive);
// Cualquier otro metodo/ruta que el agente pruebe bajo estas rutas queda registrado.
router.all(['/api/inventory', '/front/inventory.php'], (req, res) => {
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} (metodo no soportado)`);
res.status(405).json({ success: false, data: null, error: 'Usa POST' });
});
export default router;
+194
View File
@@ -0,0 +1,194 @@
import { XMLParser } from 'fast-xml-parser';
// 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
// el XML se pasa a minusculas y ambos formatos usan el mismo mapeo. Todo es tolerante: un campo
// ausente es null, nunca una excepcion (el formato varia por version del agente y por SO).
const XML_ARRAY_SECTIONS = new Set([
'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers',
'users', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus',
]);
const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, trimValues: true });
function lowerKeys(value) {
if (Array.isArray(value)) return value.map(lowerKeys);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k.toLowerCase(), lowerKeys(v)]));
}
return value;
}
const asArray = (v) => (v == null || v === '' ? [] : Array.isArray(v) ? v : [v]);
const str = (v, max = 255) => {
if (v == null) return null;
const s = String(typeof v === 'object' ? '' : v).trim();
return s ? s.slice(0, max) : null;
};
const num = (v) => {
if (v == null || v === '') return null;
const n = Number(String(v).replace(/[^\d.-]/g, ''));
return Number.isFinite(n) ? Math.round(n) : null;
};
// Devuelve { content, deviceid, itemtype, action, tag } o null si no se reconoce estructura.
export function parseInventoryText(text, format) {
if (format === 'json') {
const obj = JSON.parse(text);
return {
action: obj.action || null,
deviceid: str(obj.deviceid || obj.content?.deviceid),
itemtype: str(obj.itemtype, 60),
tag: str(obj.tag, 120),
content: lowerKeys(obj.content || obj),
};
}
const parsed = lowerKeys(xmlParser.parse(text));
const req = parsed.request || parsed;
const content = req.content || {};
for (const key of XML_ARRAY_SECTIONS) {
if (content[key] !== undefined) content[key] = asArray(content[key]);
}
return {
action: str(req.query) === 'PROLOG' ? 'prolog' : (str(req.query) || '').toLowerCase() || null,
deviceid: str(req.deviceid),
itemtype: null,
tag: str(content.accountinfo?.keyvalue, 120),
content,
};
}
// Software que NO es una aplicacion "de usuario": actualizaciones, runtimes, drivers y paquetes del
// sistema. Se decide por la categoria que reporta el agente (system_category) y, para lo que el
// agente marca como "application"/"store" pero es ruido, por el nombre.
const COMPONENT_CATEGORIES = new Set(['system', 'system_component', 'update', 'security_update', 'hotfix']);
const COMPONENT_NAME = new RegExp([
'^KB\\d{6,8}$', '\\(KB\\d{6,8}\\)', 'redistributable', 'runtime package', '\\.net native', '\\.net (host|runtime|sdk|targeting|apphost|toolset)',
'windowsappruntime', 'ui xaml', 'advertising sdk', 'vclibs', 'driver', 'controlador', 'firmware', 'chipset',
'management engine', 'rapid storage', 'serial io', 'hardware accelerated', 'compatibility fix', 'subsystem for linux',
'webview2', 'update health tools', 'pc health', 'comprobaci[oó]n de estado', 'desinstalar impresora', 'printer uninstall',
'^microsoftwindows\\.', '^[0-9a-f]{8}-[0-9a-f]{4}-', '^windows sdk', 'software development kit.*(windows|arm|x64|x86)',
].join('|'), 'i');
function isComponent(name, category) {
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
}
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;
function detectKind(parsed, hw, os) {
const type = (parsed.itemtype || '').toLowerCase();
if (type === 'printer') return 'printer';
if (type === 'networkequipment') return 'network';
const chassis = `${hw.chassis_type || ''}`;
if (hw.vmsystem && !/physical/i.test(hw.vmsystem)) return 'virtual';
if (LAPTOP_CHASSIS.test(chassis)) return 'laptop';
if (SERVER_CHASSIS.test(chassis) || /server/i.test(`${os.name || ''} ${os.full_name || ''} ${hw.osname || ''}`)) return 'server';
if (chassis) return 'desktop';
return 'other';
}
export function normalizeInventory(parsed) {
const c = parsed.content || {};
const hw = c.hardware || {};
const bios = c.bios || {};
const os = c.operatingsystem || {};
const isAgentless = !!(c.network_device || c.printers?.length) && !c.hardware?.name;
// Equipos descubiertos por SNMP (netinventory): vienen como network_device o printers.
const nd = c.network_device || {};
const printer = asArray(c.printers)[0] || {};
const cpus = asArray(c.cpus).map((x) => ({
name: str(x.name), manufacturer: str(x.manufacturer, 120),
cores: num(x.core ?? x.cores), threads: num(x.thread ?? x.threads), speed_mhz: num(x.speed),
}));
const memories = asArray(c.memories)
.map((x) => ({
slot: str(x.numslots ?? x.caption ?? x.description, 80), capacity_mb: num(x.capacity),
type: str(x.type, 60), speed: str(x.speed, 40), manufacturer: str(x.manufacturer, 120), serial: str(x.serialnumber ?? x.serial, 120),
}))
.filter((m) => m.capacity_mb);
const disks = asArray(c.storages).map((x) => ({
name: str(x.name, 190), model: str(x.model ?? x.description, 190), manufacturer: str(x.manufacturer, 120),
type: str(x.type, 60), interface: str(x.interface, 60), size_mb: num(x.disksize), serial: str(x.serial ?? x.serialnumber, 160),
}));
const volumes = asArray(c.drives).map((x) => ({
mount: str(x.letter ?? x.volumn, 190), label: str(x.label, 190), filesystem: str(x.filesystem, 60),
type: str(x.type, 60), total_mb: num(x.total), free_mb: num(x.free),
}));
const networks = asArray(c.networks)
.filter((x) => !(String(x.virtualdev) === '1' && !x.ipaddress))
.map((x) => ({
description: str(x.description), mac: str(x.macaddr, 40), ip: str(x.ipaddress, 64), mask: str(x.ipmask, 64),
gateway: str(x.ipgateway, 64), type: str(x.type, 60), status: str(x.status, 40),
}));
const monitors = asArray(c.monitors).map((x) => ({
manufacturer: str(x.manufacturer, 120), model: str(x.caption ?? x.description, 190), serial: str(x.serial, 120),
}));
const software = asArray(c.softwares)
.map((x) => {
const name = str(x.name);
const category = str(x.system_category, 40);
return {
name, version: str(x.version, 120), publisher: str(x.publisher, 190), install_date: str(x.installdate ?? x.install_date, 40),
category, is_component: isComponent(name, category) ? 1 : 0,
};
})
.filter((s) => s.name);
// Resumenes para listado/KPIs.
const hostname = str(hw.name || nd.name || printer.name || os.fqdn, 190);
const upIfaces = networks.filter((n) => n.ip && !/^(127\.|169\.254\.|::1|fe80)/i.test(n.ip) && !n.ip.includes(':'));
const mainNet = upIfaces.find((n) => /up|connected/i.test(n.status || '')) || upIfaces[0] || networks[0] || {};
const ramMb = num(hw.memory) || memories.reduce((a, m) => a + (m.capacity_mb || 0), 0) || null;
const realVolumes = volumes.filter((v) => v.total_mb > 0 && !/cd|dvd|removable|network|remote/i.test(v.type || ''));
const diskTotal = realVolumes.reduce((a, v) => a + v.total_mb, 0) || null;
const diskFree = diskTotal ? realVolumes.reduce((a, v) => a + (v.free_mb || 0), 0) : null;
const maxUsed = realVolumes.length
? Math.max(...realVolumes.map((v) => Math.round(100 - ((v.free_mb || 0) * 100) / v.total_mb)))
: null;
const kind = isAgentless && printer.name ? 'printer' : detectKind(parsed, hw, os);
const lastUser = str(hw.lastloggeduser ?? asArray(c.users)[0]?.login, 190);
const device = {
deviceid: parsed.deviceid,
hostname,
kind,
manufacturer: str(bios.smanufacturer ?? nd.manufacturer ?? printer.manufacturer, 160),
model: str(bios.smodel ?? nd.model ?? printer.model ?? printer.name, 190),
serial: str(bios.ssn ?? nd.serial ?? printer.serialnumber ?? printer.serial, 160),
os_name: str(os.full_name ?? os.name ?? hw.osname, 190),
os_version: str(os.version ?? hw.osversion, 120),
os_arch: str(os.arch, 40),
domain: str(os.domain ?? os.fqdn ?? hw.workgroup, 190),
last_user: lastUser,
ip_main: mainNet.ip || str(nd.ips?.[0] ?? nd.ip, 64) || null,
mac_main: mainNet.mac || str(nd.mac, 40) || null,
cpu_summary: cpus.length ? `${cpus[0].name || ''}${cpus.length > 1 ? ` x${cpus.length}` : ''}`.trim().slice(0, 255) || null : null,
ram_mb: ramMb,
disk_total_mb: diskTotal,
disk_free_mb: diskFree,
max_disk_used_pct: maxUsed,
agent_version: str(c.versionclient, 80),
tag: parsed.tag,
itemtype: parsed.itemtype,
};
// Lo que no tiene columna propia (impresoras/red SNMP, antivirus, video, bateria) se conserva
// como JSON para mostrarlo en el detalle sin tocar el esquema por cada tipo de equipo.
const extra = {};
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];
}
return { device, cpus, memories, disks, volumes, networks, monitors, software, extra };
}
+254
View File
@@ -0,0 +1,254 @@
import { pool } from '../../config/db.js';
const DEVICE_COLS = [
'hostname', 'kind', 'manufacturer', 'model', 'serial', 'os_name', 'os_version', 'os_arch', 'domain', 'last_user',
'ip_main', 'mac_main', 'cpu_summary', 'ram_mb', 'disk_total_mb', 'disk_free_mb', 'max_disk_used_pct',
'agent_version', 'tag', 'itemtype',
];
const CHILD_TABLES = {
cpus: { table: 'device_cpus', cols: ['name', 'manufacturer', 'cores', 'threads', 'speed_mhz'] },
memories: { table: 'device_memories', cols: ['slot', 'capacity_mb', 'type', 'speed', 'manufacturer', 'serial'] },
disks: { table: 'device_disks', cols: ['name', 'model', 'manufacturer', 'type', 'interface', 'size_mb', 'serial'] },
volumes: { table: 'device_volumes', cols: ['mount', 'label', 'filesystem', 'type', 'total_mb', 'free_mb'] },
networks: { table: 'device_networks', cols: ['description', 'mac', 'ip', 'mask', 'gateway', 'type', 'status'] },
monitors: { table: 'device_monitors', cols: ['manufacturer', 'model', 'serial'] },
software: { table: 'device_software', cols: ['name', 'version', 'publisher', 'install_date', 'category', 'is_component'] },
};
// ---------- Tokens de ingesta ----------
export async function findActiveTokenByHash(hash) {
const [rows] = await pool.query('SELECT id FROM inventory_tokens WHERE token_hash = ? AND is_active = 1', [hash]);
return rows[0] || null;
}
export const touchToken = (id) => pool.query('UPDATE inventory_tokens SET last_used_at = NOW() WHERE id = ?', [id]);
export async function listTokens() {
const [rows] = await pool.query(
`SELECT t.id, t.label, t.is_active, t.last_used_at, t.created_at, u.name AS created_by_name
FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC`
);
return rows;
}
export async function createToken(label, hash, userId) {
const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, created_by) VALUES (?, ?, ?)', [label, hash, userId]);
return r.insertId;
}
export const setTokenActive = (id, active) => pool.query('UPDATE inventory_tokens SET is_active = ? WHERE id = ?', [active ? 1 : 0, id]);
export const deleteToken = (id) => pool.query('DELETE FROM inventory_tokens WHERE id = ?', [id]);
// ---------- Ingesta ----------
// Foto de lo que hay hoy en BD (para calcular el diff contra el reporte nuevo).
async function snapshotOf(conn, deviceId) {
const [[dev]] = await conn.query('SELECT hostname, os_name, os_version, ram_mb FROM devices WHERE id = ?', [deviceId]);
const [disks] = await conn.query('SELECT model, serial, size_mb FROM device_disks WHERE device_id = ?', [deviceId]);
const [software] = await conn.query('SELECT name, version FROM device_software WHERE device_id = ?', [deviceId]);
return { dev, disks, software };
}
function diffSnapshots(before, after) {
const changes = [];
for (const f of ['hostname', 'os_name', 'os_version', 'ram_mb']) {
if ((before.dev[f] ?? null) !== (after.dev[f] ?? null)) {
changes.push({ change_type: 'field', field: f, old_value: before.dev[f], new_value: after.dev[f] });
}
}
const diskKey = (d) => `${d.model || ''}|${d.serial || ''}|${d.size_mb || ''}`;
const swKey = (s) => `${s.name}|${s.version || ''}`;
const compare = (a, b, keyFn, labelFn, addType, removeType) => {
const ka = new Set(a.map(keyFn));
const kb = new Set(b.map(keyFn));
for (const x of b) if (!ka.has(keyFn(x))) changes.push({ change_type: addType, field: null, old_value: null, new_value: labelFn(x) });
for (const x of a) if (!kb.has(keyFn(x))) changes.push({ change_type: removeType, field: null, old_value: labelFn(x), new_value: null });
};
compare(before.disks, after.disks, diskKey, (d) => `${d.model || 'Disco'} ${d.size_mb ? Math.round(d.size_mb / 1024) + ' GB' : ''}`.trim(), 'disk_added', 'disk_removed');
compare(before.software, after.software, swKey, (s) => `${s.name}${s.version ? ` ${s.version}` : ''}`, 'software_installed', 'software_removed');
return changes;
}
// Upsert por deviceid. Reemplaza todos los componentes dentro de una transaccion y registra el diff.
// Un equipo dado de baja (status=retired) sigue actualizando datos pero NO se reactiva solo.
export async function upsertInventory(deviceid, n, rawFile) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [[existing]] = await conn.query('SELECT id FROM devices WHERE deviceid = ? FOR UPDATE', [deviceid]);
const d = n.device;
const values = DEVICE_COLS.map((c) => d[c] ?? null);
const extraJson = Object.keys(n.extra).length ? JSON.stringify(n.extra) : null;
let deviceId;
let before = null;
if (existing) {
deviceId = existing.id;
before = await snapshotOf(conn, deviceId);
await conn.query(
`UPDATE devices SET ${DEVICE_COLS.map((c) => `${c} = ?`).join(', ')}, last_seen = NOW(), raw_file = ?, extra_json = ? WHERE id = ?`,
[...values, rawFile, extraJson, deviceId]
);
for (const { table } of Object.values(CHILD_TABLES)) await conn.query(`DELETE FROM ${table} WHERE device_id = ?`, [deviceId]);
} else {
const [r] = await conn.query(
`INSERT INTO devices (deviceid, ${DEVICE_COLS.join(', ')}, raw_file, extra_json) VALUES (?, ${DEVICE_COLS.map(() => '?').join(', ')}, ?, ?)`,
[deviceid, ...values, rawFile, extraJson]
);
deviceId = r.insertId;
}
for (const [key, { table, cols }] of Object.entries(CHILD_TABLES)) {
const rows = n[key];
if (!rows.length) continue;
await conn.query(
`INSERT INTO ${table} (device_id, ${cols.join(', ')}) VALUES ?`,
[rows.map((row) => [deviceId, ...cols.map((c) => row[c] ?? null)])]
);
}
let changeCount = 0;
if (before) {
const after = {
dev: d,
disks: n.disks,
software: n.software,
};
const changes = diffSnapshots(before, after);
changeCount = changes.length;
if (changes.length) {
await conn.query(
'INSERT INTO device_changes (device_id, change_type, field, old_value, new_value) VALUES ?',
[changes.map((c) => [deviceId, c.change_type, c.field, c.old_value == null ? null : String(c.old_value), c.new_value == null ? null : String(c.new_value)])]
);
}
}
await conn.commit();
return { id: deviceId, created: !existing, changeCount };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
// ---------- Consultas de la UI ----------
export async function findAll({ search, kind, status, staleDays, diskFull }) {
const where = [];
const params = [];
if (status) { where.push('d.status = ?'); params.push(status); }
if (kind) { where.push('d.kind = ?'); params.push(kind); }
if (staleDays) { where.push('d.last_seen < NOW() - INTERVAL ? DAY'); params.push(staleDays); }
if (diskFull) where.push('d.max_disk_used_pct >= 90');
if (search) {
where.push(`(d.hostname LIKE ? OR d.serial LIKE ? OR d.model LIKE ? OR d.ip_main LIKE ? OR d.mac_main LIKE ? OR d.last_user LIKE ? OR u.name LIKE ?)`);
params.push(...Array(7).fill(`%${search}%`));
}
const [rows] = await pool.query(
`SELECT d.id, d.hostname, d.kind, d.manufacturer, d.model, d.serial, d.os_name, d.os_version, d.ip_main, d.mac_main,
d.last_user, d.ram_mb, d.disk_total_mb, d.max_disk_used_pct, d.first_seen, d.last_seen, d.status,
d.assigned_user_id, u.name AS assigned_user_name, d.department_id, dep.name AS department_name
FROM devices d
LEFT JOIN users u ON u.id = d.assigned_user_id
LEFT JOIN departments dep ON dep.id = d.department_id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY d.hostname`,
params
);
return rows;
}
export async function findById(id) {
const [[device]] = await pool.query(
`SELECT d.*, u.name AS assigned_user_name, dep.name AS department_name
FROM devices d
LEFT JOIN users u ON u.id = d.assigned_user_id
LEFT JOIN departments dep ON dep.id = d.department_id
WHERE d.id = ?`,
[id]
);
if (!device) return null;
const detail = { ...device, extra: device.extra_json ? JSON.parse(device.extra_json) : null };
delete detail.extra_json;
for (const [key, { table }] of Object.entries(CHILD_TABLES)) {
const order = key === 'software' ? 'name' : 'id';
const [rows] = await pool.query(`SELECT * FROM ${table} WHERE device_id = ? ORDER BY ${order}`, [id]);
detail[key] = rows;
}
const [changes] = await pool.query(
'SELECT id, change_type, field, old_value, new_value, created_at FROM device_changes WHERE device_id = ? ORDER BY created_at DESC, id DESC LIMIT 200',
[id]
);
detail.changes = changes;
return detail;
}
export async function updateManual(id, { status, assignedUserId, departmentId, notes }) {
await pool.query(
'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ? WHERE id = ?',
[status, assignedUserId, departmentId, notes, id]
);
}
export async function findRawFile(id) {
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
return row?.raw_file || null;
}
// Por defecto solo aplicaciones; `all` incluye actualizaciones/runtimes/drivers (is_component).
export async function listSoftware({ search, all }) {
const params = [];
const clauses = [];
if (!all) clauses.push('s.is_component = 0');
if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [rows] = await pool.query(
`SELECT s.name, MAX(s.publisher) AS publisher, COUNT(DISTINCT s.device_id) AS device_count,
COUNT(DISTINCT s.version) AS version_count
FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active'
${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`,
params
);
return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) }));
}
export async function listDevicesWithSoftware(name) {
const [rows] = await pool.query(
`SELECT d.id, d.hostname, d.kind, d.last_user, s.version
FROM device_software s JOIN devices d ON d.id = s.device_id
WHERE s.name = ? AND d.status = 'active' ORDER BY d.hostname`,
[name]
);
return rows;
}
export async function dashboard(staleDays) {
const one = async (sql, params = []) => (await pool.query(sql, params))[0];
const [[totals]] = await pool.query(
`SELECT COUNT(*) AS total,
SUM(last_seen < NOW() - INTERVAL ? DAY) AS stale,
SUM(max_disk_used_pct >= 90) AS disk_full,
SUM(ram_mb IS NOT NULL AND ram_mb < 4096) AS low_ram
FROM devices WHERE status = 'active'`,
[staleDays]
);
const byKind = await one(`SELECT kind AS label, COUNT(*) AS value FROM devices WHERE status = 'active' GROUP BY kind ORDER BY value DESC`);
const byOs = await one(`SELECT COALESCE(os_name, '—') AS label, COUNT(*) AS value FROM devices WHERE status = 'active' GROUP BY os_name ORDER BY value DESC LIMIT 8`);
const topSoftware = await one(
`SELECT s.name AS label, COUNT(DISTINCT s.device_id) AS value
FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active'
WHERE s.is_component = 0
GROUP BY s.name ORDER BY value DESC LIMIT 8`
);
const n = (v) => Number(v || 0);
return {
total: n(totals.total), stale: n(totals.stale), diskFull: n(totals.disk_full), lowRam: n(totals.low_ram),
byKind: byKind.map((r) => ({ ...r, value: n(r.value) })),
byOs: byOs.map((r) => ({ ...r, value: n(r.value) })),
topSoftware: topSoftware.map((r) => ({ ...r, value: n(r.value) })),
};
}
+27
View File
@@ -0,0 +1,27 @@
import { Router } from 'express';
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
import {
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
getTokens, postToken, patchToken, deleteToken,
} from './controller.js';
const router = Router();
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente, solo admin/superadmin.
const CAN_VIEW = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER];
const CAN_MANAGE_TOKENS = [ROLES.SUPERADMIN, ROLES.ADMIN];
router.use(requireAuth, requireRole(...CAN_VIEW));
router.get('/dashboard', getDashboard);
router.get('/software', getSoftware);
router.get('/software/devices', getSoftwareDevices);
router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens);
router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken);
router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken);
router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken);
router.get('/', getDevices);
router.get('/:id', getDevice);
router.get('/:id/raw', getRaw);
router.put('/:id', putDevice);
export default router;
+75
View File
@@ -0,0 +1,75 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { env } from '../../config/env.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import * as repo from './repository.js';
const STATUSES = ['active', 'retired'];
const STALE_DAYS = 7;
export const listDevices = (filters) => repo.findAll({
search: filters.q?.trim() || null,
kind: filters.kind || null,
status: filters.status || null,
staleDays: filters.stale ? STALE_DAYS : null,
diskFull: !!filters.diskFull,
});
export async function getDevice(id) {
const device = await repo.findById(id);
if (!device) throw new HttpError(404, 'Equipo no encontrado');
return device;
}
export async function updateDevice(id, data, actor) {
const current = await getDevice(id);
const status = data.status ?? current.status;
if (!STATUSES.includes(status)) throw new HttpError(400, 'Estado invalido');
const next = {
status,
assignedUserId: data.assignedUserId === undefined ? current.assigned_user_id : data.assignedUserId || null,
departmentId: data.departmentId === undefined ? current.department_id : data.departmentId || null,
notes: data.notes === undefined ? current.notes : (String(data.notes).trim() || null),
};
await repo.updateManual(id, next);
await logAudit({ userId: actor.id, action: 'update', entityType: 'device', entityId: id, detail: { hostname: current.hostname, ...next } });
return getDevice(id);
}
export async function getRaw(id) {
const file = await repo.findRawFile(id);
if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo');
const full = path.join(env.storageRoot, 'inventories', path.basename(file));
if (!fs.existsSync(full)) throw new HttpError(404, 'Archivo de inventario no encontrado');
return { full, file };
}
export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() || null, all: !!all });
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
export const getDashboard = () => repo.dashboard(STALE_DAYS);
export const listTokens = () => repo.listTokens();
// El token en claro se devuelve UNA sola vez (aqui); en BD queda solo el hash.
export async function createToken(label, actor) {
const clean = String(label || '').trim();
if (!clean) throw new HttpError(400, 'La etiqueta es obligatoria');
const token = crypto.randomBytes(24).toString('hex');
const id = await repo.createToken(clean.slice(0, 120), crypto.createHash('sha256').update(token).digest('hex'), actor.id);
await logAudit({ userId: actor.id, action: 'create', entityType: 'inventory_token', entityId: id, detail: { label: clean } });
return { id, label: clean, token };
}
export async function setTokenActive(id, active, actor) {
await repo.setTokenActive(id, active);
await logAudit({ userId: actor.id, action: active ? 'enable' : 'disable', entityType: 'inventory_token', entityId: id });
return repo.listTokens();
}
export async function removeToken(id, actor) {
await repo.deleteToken(id);
await logAudit({ userId: actor.id, action: 'delete', entityType: 'inventory_token', entityId: id });
return repo.listTokens();
}
+71
View File
@@ -0,0 +1,71 @@
# Arquitectura de inventario — menú "Equipos"
Diseño del módulo Equipos de Project. Se trajo de `linkedtrack.linkedcorp.net` (que sigue como
producto aparte, sin cambios) y se reescribió siguiendo las reglas de Project: mismo login, roles,
auditoría, UI Fluent y una sola base `project_linkedcorp`.
## Decisión de fondo
| Opción | Veredicto |
|---|---|
| Servidor GLPI completo | Descartado: demasiada administración para solo inventariar. |
| Agente 100% propio | Descartado: reescribir el sondeo de hardware (WMI, EDID, SNMP) es frágil y caro. |
| **Agente GLPI oficial + colector propio dentro de Project** | **Elegido.** El agente recolecta; Project recibe, guarda y muestra. |
## Flujo
```
GLPI Agent (cada PC/servidor) --POST inventario--> https://project.linkedcorp.net/api/inventory
(ingest.js: token -> inflar -> parsear JSON/XML
-> normalizer.js -> upsert por deviceid
-> diff en device_changes)
Navegador (superadmin/admin/it_manager) --JWT--> /api/devices -> menú "Equipos"
```
- **Handshake:** el agente primero manda un `contact` (JSON) o `PROLOG` (XML). Se responde sin
guardar nada (`{"status":"ok","expiration":"24"}` o `<REPLY><RESPONSE>SEND</RESPONSE>…`).
- **Ingesta autenticada por token**, no por JWT: tabla `inventory_tokens` (solo hash sha256). El
agente lo manda como contraseña Basic (`user`/`password` del `agent.cfg`), `Bearer`,
`X-Ingest-Token` o `?token=`. Sin token válido → `401`.
- **Rutas:** `POST /api/inventory` (propia; pasa por el Custom Location `/api` de NPM sin cambios)
y alias `POST /front/inventory.php` (ruta nativa de GLPI). Se montan ANTES de `express.json`
porque usan cuerpo crudo.
- **Límites:** cuerpo máx. 15 MB (`INVENTORY_MAX_BYTES`), inflado máx. 4× (anti zip-bomb).
- **Upsert por `deviceid`** (estable), nunca por hostname. Componentes se reemplazan completos en
una transacción; el diff (RAM, disco agregado/retirado, software instalado/desinstalado, SO,
hostname) queda en `device_changes`.
- **Baja lógica:** `status = retired`; un reporte nuevo NO reactiva un equipo dado de baja.
- **Crudo para depurar:** último reporte por equipo en `storage/inventories/<hash>.json|xml`
(se sobrescribe; descarga `GET /api/devices/:id/raw`).
## Normalización
`backend/src/modules/devices/normalizer.js`: el JSON nativo y el XML (en mayúsculas) comparten
nombres de campo, así que el XML se pasa a minúsculas y ambos usan el mismo mapeo. Todo campo
ausente queda `null` (el formato varía por versión del agente y SO). Tipo de equipo
(`desktop/laptop/server/virtual/printer/network/other`) se infiere de `chassis_type`, `vmsystem`,
nombre del SO e `itemtype`. Lo que no tiene columna (impresoras/red SNMP, antivirus, video,
batería) se guarda en `devices.extra_json`.
## Instalar el agente en un equipo
1. Superadmin/admin: menú **Equipos → Agentes → Generar token** (se muestra una sola vez).
2. En el equipo, instalar GLPI Agent (MSI en Windows; `.deb`/`.rpm`/tarball en Linux) con:
```
server = https://project.linkedcorp.net/api/inventory
user = project
password = <token>
tag = <opcional>
```
Windows (instalador): `SERVER=… USER=project PASSWORD=<token>`.
3. Forzar un envío: `glpi-agent --force` (Linux) / "Forzar inventario" en la página del agente
(`http://localhost:62354`) en Windows. El equipo aparece en **Equipos** al primer POST.
## Pendientes / por verificar con un agente real
- Confirmar que el agente acepta la respuesta del handshake tal cual (si no, ajustar `ingest.js`).
- Capturar un JSON real (`glpi-agent --local=/tmp/inv --json`) y contrastar campos con
`normalizer.js` (hoy basado en el formato documentado del agente).
- Impresoras/red por SNMP (`netinventory`): solo se guarda lo básico + `extra_json`; falta una
vista propia cuando haya un caso real.
- Panel de impresoras con contador de páginas/tóner y export CSV: fase 2.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "2.14.0",
"version": "2.15.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -21,6 +21,7 @@ import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx';
import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx';
import NotasPage from '../pages/Notas/NotasPage.jsx';
import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx';
import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
export default function App() {
@@ -45,6 +46,7 @@ export default function App() {
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
<Route path="notas" element={<NotasPage />} />
<Route path="contrasenas" element={<ContrasenasPage />} />
<Route path="equipos" element={<EquiposPage />} />
<Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} />
</Route>
+3 -2
View File
@@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound } from 'lucide-react';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAuth } from '../context/auth-context.jsx';
import { useBranding } from '../context/branding-context.jsx';
@@ -12,6 +12,7 @@ const ITEMS = [
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
{ to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' },
{ to: '/equipos', icon: Monitor, labelKey: 'nav_devices', roles: ['superadmin', 'admin', 'it_manager'] },
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
];
@@ -23,7 +24,7 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
// En celular el drawer siempre se ve completo (con etiquetas), sin importar el modo
// compacto/expandido del riel de escritorio -- son dos cosas independientes.
const compact = mode === 'compact' && !mobileOpen;
const items = ITEMS.filter((item) => !item.superadminOnly || user?.roleCode === 'superadmin');
const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.roles || item.roles.includes(user?.roleCode)));
const brandName = branding.name || t('appName');
return (
<nav className={`nav-view ${mode} ${mobileOpen ? 'mobile-open' : ''}`}>
+182
View File
@@ -190,6 +190,97 @@ export const messages = {
nav_recurring_tasks: 'Tareas recurrentes',
nav_notes: 'Notas',
nav_passwords: 'Contraseñas',
nav_devices: 'Equipos',
eq_tab_summary: 'Resumen',
eq_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema',
eq_btn_info: 'Información',
eq_tab_devices: 'Equipos',
eq_tab_software_all: 'Software',
eq_tab_agents: 'Agentes',
eq_tab_general: 'General',
eq_tab_hardware: 'Hardware',
eq_tab_network: 'Red',
eq_tab_software: 'Software',
eq_tab_history: 'Historial',
eq_detail: 'Detalle del equipo',
eq_empty: 'Aún no hay equipos. Instala el agente GLPI en un equipo apuntando a este sistema (pestaña Agentes).',
eq_hostname: 'Nombre',
eq_kind: 'Tipo',
eq_kind_desktop: 'PC de escritorio',
eq_kind_laptop: 'Portátil',
eq_kind_server: 'Servidor',
eq_kind_virtual: 'Máquina virtual',
eq_kind_printer: 'Impresora',
eq_kind_network: 'Equipo de red',
eq_kind_other: 'Otro',
eq_status_active: 'Activo',
eq_status_retired: 'De baja',
eq_manufacturer_model: 'Marca y modelo',
eq_manufacturer: 'Fabricante',
eq_model: 'Modelo',
eq_serial: 'N.º de serie',
eq_os: 'Sistema operativo',
eq_domain: 'Dominio',
eq_last_user: 'Último usuario',
eq_ip: 'IP',
eq_first_seen: 'Primer reporte',
eq_last_seen: 'Último reporte',
eq_agent: 'Versión del agente',
eq_assigned_to: 'Asignado a',
eq_notes: 'Notas',
eq_cpu: 'Procesador',
eq_ram: 'Memoria RAM',
eq_disk_total: 'Almacenamiento',
eq_disk_max_used: 'volumen más lleno',
eq_memories: 'Módulos de memoria',
eq_disks: 'Discos físicos',
eq_volumes: 'Volúmenes',
eq_monitors: 'Monitores',
eq_slot: 'Ranura',
eq_capacity: 'Capacidad',
eq_type: 'Tipo',
eq_speed: 'Velocidad',
eq_mount: 'Unidad',
eq_fs: 'Sistema de archivos',
eq_free: 'Libre',
eq_adapter: 'Adaptador',
eq_mask: 'Máscara',
eq_gateway: 'Puerta de enlace',
eq_version: 'Versión',
eq_versions: 'Versiones',
eq_publisher: 'Editor',
eq_installed_on: 'Equipos',
eq_when: 'Fecha',
eq_change: 'Cambio',
eq_field: 'Campo',
eq_before: 'Antes',
eq_after: 'Después',
eq_history_empty: 'Sin cambios registrados entre reportes.',
eq_change_field: 'Dato modificado',
eq_change_disk_added: 'Disco agregado',
eq_change_disk_removed: 'Disco retirado',
eq_change_software_installed: 'Software instalado',
eq_change_software_removed: 'Software desinstalado',
eq_kpi_total: 'Equipos activos',
eq_kpi_stale: 'Sin reportar (+7 días)',
eq_kpi_disk_full: 'Disco ≥ 90% lleno',
eq_kpi_low_ram: 'RAM menor a 4 GB',
eq_by_os: 'Por sistema operativo',
eq_by_kind: 'Por tipo',
eq_top_software: 'Software más instalado',
eq_agent_setup: 'Conectar un agente GLPI',
eq_agent_setup_hint: 'En el archivo de configuración del agente (o en el instalador) usa esta dirección como servidor:',
eq_agent_setup_auth: 'Autenticación: usuario cualquiera y, como contraseña, el token generado abajo.',
eq_token_label: 'Etiqueta del token (ej. Oficina central)',
eq_token_new: 'Generar token',
eq_token_created: 'Token generado para',
eq_token_once: 'Cópialo ahora: no se vuelve a mostrar. En el sistema solo queda su huella cifrada.',
eq_token_last_used: 'Último uso',
eq_token_created_by: 'Creado por',
eq_token_enable: 'Activar',
eq_token_disable: 'Desactivar',
eq_token_delete_msg: 'Los agentes que usen este token dejarán de poder reportar. ¿Eliminarlo?',
eq_copied: 'Copiado',
vault_pin_label: 'PIN (4 números)',
vault_pin_confirm_label: 'Confirmar PIN',
vault_pin_current_label: 'PIN actual',
@@ -555,6 +646,97 @@ export const messages = {
nav_recurring_tasks: 'Recurring tasks',
nav_notes: 'Notes',
nav_passwords: 'Passwords',
nav_devices: 'Devices',
eq_tab_summary: 'Summary',
eq_show_components: 'Show updates, drivers and system components',
eq_btn_info: 'Information',
eq_tab_devices: 'Devices',
eq_tab_software_all: 'Software',
eq_tab_agents: 'Agents',
eq_tab_general: 'General',
eq_tab_hardware: 'Hardware',
eq_tab_network: 'Network',
eq_tab_software: 'Software',
eq_tab_history: 'History',
eq_detail: 'Device detail',
eq_empty: 'No devices yet. Install the GLPI agent on a machine pointing to this system (Agents tab).',
eq_hostname: 'Name',
eq_kind: 'Type',
eq_kind_desktop: 'Desktop',
eq_kind_laptop: 'Laptop',
eq_kind_server: 'Server',
eq_kind_virtual: 'Virtual machine',
eq_kind_printer: 'Printer',
eq_kind_network: 'Network device',
eq_kind_other: 'Other',
eq_status_active: 'Active',
eq_status_retired: 'Retired',
eq_manufacturer_model: 'Make and model',
eq_manufacturer: 'Manufacturer',
eq_model: 'Model',
eq_serial: 'Serial no.',
eq_os: 'Operating system',
eq_domain: 'Domain',
eq_last_user: 'Last user',
eq_ip: 'IP',
eq_first_seen: 'First report',
eq_last_seen: 'Last report',
eq_agent: 'Agent version',
eq_assigned_to: 'Assigned to',
eq_notes: 'Notes',
eq_cpu: 'Processor',
eq_ram: 'RAM',
eq_disk_total: 'Storage',
eq_disk_max_used: 'fullest volume',
eq_memories: 'Memory modules',
eq_disks: 'Physical disks',
eq_volumes: 'Volumes',
eq_monitors: 'Monitors',
eq_slot: 'Slot',
eq_capacity: 'Capacity',
eq_type: 'Type',
eq_speed: 'Speed',
eq_mount: 'Drive',
eq_fs: 'File system',
eq_free: 'Free',
eq_adapter: 'Adapter',
eq_mask: 'Mask',
eq_gateway: 'Gateway',
eq_version: 'Version',
eq_versions: 'Versions',
eq_publisher: 'Publisher',
eq_installed_on: 'Devices',
eq_when: 'Date',
eq_change: 'Change',
eq_field: 'Field',
eq_before: 'Before',
eq_after: 'After',
eq_history_empty: 'No changes recorded between reports.',
eq_change_field: 'Field changed',
eq_change_disk_added: 'Disk added',
eq_change_disk_removed: 'Disk removed',
eq_change_software_installed: 'Software installed',
eq_change_software_removed: 'Software removed',
eq_kpi_total: 'Active devices',
eq_kpi_stale: 'Not reporting (+7 days)',
eq_kpi_disk_full: 'Disk ≥ 90% full',
eq_kpi_low_ram: 'RAM under 4 GB',
eq_by_os: 'By operating system',
eq_by_kind: 'By type',
eq_top_software: 'Most installed software',
eq_agent_setup: 'Connect a GLPI agent',
eq_agent_setup_hint: 'In the agent configuration file (or installer) use this address as the server:',
eq_agent_setup_auth: 'Authentication: any user name and, as password, the token generated below.',
eq_token_label: 'Token label (e.g. Head office)',
eq_token_new: 'Generate token',
eq_token_created: 'Token generated for',
eq_token_once: 'Copy it now: it is not shown again. Only its hash is stored.',
eq_token_last_used: 'Last used',
eq_token_created_by: 'Created by',
eq_token_enable: 'Enable',
eq_token_disable: 'Disable',
eq_token_delete_msg: 'Agents using this token will no longer be able to report. Delete it?',
eq_copied: 'Copied',
vault_pin_label: 'PIN (4 digits)',
vault_pin_confirm_label: 'Confirm PIN',
vault_pin_current_label: 'Current PIN',
+486
View File
@@ -0,0 +1,486 @@
import { useState } from 'react';
import { Pencil, Save, Plus, Trash2, Power, Copy, Info } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import {
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken,
} from '../../services/devices.js';
import { listUsers } from '../../services/users.js';
import { listDepartments } from '../../services/departments.js';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { SearchInput } from '../../components/SearchInput.jsx';
import { ListView } from '../../components/ListView.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { Badge } from '../../components/Badge.jsx';
import { KpiCard } from '../../components/KpiCard.jsx';
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
const fmtDate = (v) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—');
const gb = (mb) => (mb ? `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} GB` : '—');
const dash = (v) => (v === null || v === undefined || v === '' ? '—' : v);
const daysAgo = (v) => (v ? (Date.now() - new Date(String(v).replace(' ', 'T')).getTime()) / 86400000 : Infinity);
function Row({ label, children }) {
return (
<div style={{ display: 'flex', gap: 12, padding: '5px 0', fontSize: '.82rem' }}>
<span className="text-muted" style={{ width: 150, flexShrink: 0 }}>{label}</span>
<span style={{ flex: 1, wordBreak: 'break-word' }}>{children}</span>
</div>
);
}
function SimpleTable({ columns, rows, empty }) {
if (!rows?.length) return <div className="text-muted">{empty}</div>;
return (
<div className="list-card">
<table className="list-table">
<thead><tr>{columns.map((c) => <th key={c.key}>{c.label}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.id ?? i}>{columns.map((c) => <td key={c.key}>{c.render ? c.render(r) : dash(r[c.key])}</td>)}</tr>
))}
</tbody>
</table>
</div>
);
}
function DeviceDetail({ id, onClose, onChanged }) {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
const { data: users } = useAsync(listUsers, []);
const { data: departments } = useAsync(listDepartments, []);
const [tab, setTab] = useState('general');
const [searches, setSearches] = useState({});
const [showComponents, setShowComponents] = useState(false);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({});
const flat = (list) => (list || []).flatMap((d) => [d, ...(d.children ? flat(d.children) : [])]);
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
function startEdit() {
setForm({
status: device.status,
assignedUserId: device.assigned_user_id || '',
departmentId: device.department_id || '',
notes: device.notes || '',
});
setEditing(true);
}
function save() {
const changed = form.status !== device.status
|| Number(form.assignedUserId || 0) !== Number(device.assigned_user_id || 0)
|| Number(form.departmentId || 0) !== Number(device.department_id || 0)
|| form.notes !== (device.notes || '');
if (!changed) { setEditing(false); return; }
runAction(async () => {
await updateDevice(id, {
status: form.status,
assignedUserId: form.assignedUserId ? Number(form.assignedUserId) : null,
departmentId: form.departmentId ? Number(form.departmentId) : null,
notes: form.notes,
});
setEditing(false);
await reload();
onChanged();
pushToast({ tone: 'success', message: t('common_saved') });
});
}
if (!device) return <div className="text-muted">{t('common_loading')}</div>;
const tabs = [
{ key: 'general', label: t('eq_tab_general') },
{ 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: 'history', label: t('eq_tab_history'), count: device.changes.length },
];
// Un buscador por pestaña (su texto se conserva al cambiar de pestaña y volver).
const q = (searches[tab] || '').trim().toLowerCase();
const hit = (...values) => !q || values.join(' ').toLowerCase().includes(q);
const emptyText = q ? t('common_search_no_results') : t('common_empty');
const only = (rows, fn) => rows.filter((r) => hit(...fn(r)));
const showRows = (list) => list.filter(([label, , text]) => hit(label, text)).map(([label, node]) => <Row key={label} label={label}>{node}</Row>);
const val = (v) => dash(v);
const infoRows = [
[t('eq_hostname'), val(device.hostname), device.hostname],
[t('eq_kind'), t(`eq_kind_${device.kind}`), t(`eq_kind_${device.kind}`)],
[t('eq_manufacturer_model'), val([device.manufacturer, device.model].filter(Boolean).join(' ')), `${device.manufacturer || ''} ${device.model || ''}`],
[t('eq_serial'), val(device.serial), device.serial],
[t('eq_os'), val([device.os_name, device.os_version].filter(Boolean).join(' ')), `${device.os_name || ''} ${device.os_version || ''}`],
[t('eq_domain'), val(device.domain), device.domain],
[t('eq_last_user'), val(device.last_user), device.last_user],
[t('eq_ip'), val(device.ip_main), device.ip_main],
[t('eq_first_seen'), fmtDate(device.first_seen), fmtDate(device.first_seen)],
[t('eq_last_seen'), fmtDate(device.last_seen), fmtDate(device.last_seen)],
[t('eq_agent'), val(device.agent_version), device.agent_version],
];
const manualRows = [
[t('common_status'), <Badge color={device.status === 'active' ? 'success' : 'muted'}>{t(`eq_status_${device.status}`)}</Badge>, t(`eq_status_${device.status}`)],
[t('eq_assigned_to'), val(device.assigned_user_name), device.assigned_user_name],
[t('common_department'), val(device.department_name), device.department_name],
[t('eq_notes'), <span style={{ whiteSpace: 'pre-wrap' }}>{val(device.notes)}</span>, device.notes],
];
const cpuText = device.cpus.map((c) => `${c.name || ''}${c.cores ? ` · ${c.cores}c/${c.threads || '?'}t` : ''}`).join(' | ');
const hwRows = [
[t('eq_cpu'), val(cpuText), cpuText],
[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 ?? ''}`],
];
const memories = only(device.memories, (r) => [r.slot, gb(r.capacity_mb), r.type, r.speed, r.manufacturer]);
const disks = only(device.disks, (r) => [r.model, r.type, gb(r.size_mb), r.serial]);
const volumes = only(device.volumes, (r) => [r.mount, r.filesystem, gb(r.total_mb), gb(r.free_mb)]);
const monitors = only(device.monitors, (r) => [r.manufacturer, r.model, r.serial]);
const hwSection = (title, rows, columns) => (!q || rows.length > 0) && (
<>
<h4>{title}</h4>
<SimpleTable empty={emptyText} rows={rows} columns={columns} />
</>
);
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 changes = only(device.changes, (r) => [fmtDate(r.created_at), t(`eq_change_${r.change_type}`), r.field, r.old_value, r.new_value]);
return (
<>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
<div style={{ marginTop: 12 }}>
<div className="toolbar" style={{ marginBottom: 10 }}>
{tab === 'software' ? (
<label className="checkbox-row">
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
{t('eq_show_components')}
</label>
) : <div />}
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
</div>
{tab === 'general' && (
<>
{showRows(infoRows)}
<hr style={{ margin: '12px 0', border: 0, borderTop: '1px solid rgb(var(--line))' }} />
{!editing ? (
<>
{showRows(manualRows)}
{q && showRows(infoRows).length + showRows(manualRows).length === 0 && <div className="text-muted">{t('common_search_no_results')}</div>}
<div style={{ marginTop: 10 }}><Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button></div>
</>
) : (
<>
<Field label={t('common_status')}>
<ComboBox
options={[{ value: 'active', label: t('eq_status_active') }, { value: 'retired', label: t('eq_status_retired') }]}
value={form.status} onChange={(v) => setForm({ ...form, status: v })}
/>
</Field>
<Field label={t('eq_assigned_to')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.assignedUserId} onChange={(v) => setForm({ ...form, assignedUserId: v })} />
</Field>
<Field label={t('common_department')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm({ ...form, departmentId: v })} />
</Field>
<Field label={t('eq_notes')}>
<textarea className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
</div>
</>
)}
</>
)}
{tab === 'hardware' && (
<>
{showRows(hwRows)}
{hwSection(t('eq_memories'), memories, [
{ key: 'slot', label: t('eq_slot') }, { key: 'capacity_mb', label: t('eq_capacity'), render: (r) => gb(r.capacity_mb) },
{ key: 'type', label: t('eq_type') }, { key: 'speed', label: t('eq_speed') }, { key: 'manufacturer', label: t('eq_manufacturer') },
])}
{hwSection(t('eq_disks'), disks, [
{ key: 'model', label: t('eq_model') }, { key: 'type', label: t('eq_type') },
{ key: 'size_mb', label: t('eq_capacity'), render: (r) => gb(r.size_mb) }, { key: 'serial', label: t('eq_serial') },
])}
{hwSection(t('eq_volumes'), volumes, [
{ key: 'mount', label: t('eq_mount') }, { key: 'filesystem', label: t('eq_fs') },
{ key: 'total_mb', label: t('eq_capacity'), render: (r) => gb(r.total_mb) }, { key: 'free_mb', label: t('eq_free'), render: (r) => gb(r.free_mb) },
])}
{hwSection(t('eq_monitors'), monitors, [
{ key: 'manufacturer', label: t('eq_manufacturer') }, { key: 'model', label: t('eq_model') }, { key: 'serial', label: t('eq_serial') },
])}
{q && showRows(hwRows).length + memories.length + disks.length + volumes.length + monitors.length === 0 && <div className="text-muted">{t('common_search_no_results')}</div>}
</>
)}
{tab === 'network' && (
<SimpleTable empty={emptyText} rows={networks} columns={[
{ key: 'description', label: t('eq_adapter') }, { key: 'ip', label: 'IP' }, { key: 'mask', label: t('eq_mask') },
{ key: 'gateway', label: t('eq_gateway') }, { key: 'mac', label: 'MAC' }, { key: 'status', label: t('common_status') },
]} />
)}
{tab === 'software' && (
<SimpleTable empty={emptyText} rows={software} columns={[
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') }, { key: 'publisher', label: t('eq_publisher') },
]} />
)}
{tab === 'history' && (
<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: 'change_type', label: t('eq_change'), render: (r) => t(`eq_change_${r.change_type}`) },
{ key: 'field', label: t('eq_field') },
{ key: 'old_value', label: t('eq_before') }, { key: 'new_value', label: t('eq_after') },
]} />
)}
</div>
</>
);
}
function SummaryTab() {
const { t } = useUi();
const { data: kpis } = useAsync(getDevicesDashboard, []);
if (!kpis) return <div className="text-muted">{t('common_loading')}</div>;
return (
<>
<div className="kpi-grid">
<KpiCard label={t('eq_kpi_total')} value={kpis.total} />
<KpiCard label={t('eq_kpi_stale')} value={kpis.stale} />
<KpiCard label={t('eq_kpi_disk_full')} value={kpis.diskFull} />
<KpiCard label={t('eq_kpi_low_ram')} value={kpis.lowRam} />
</div>
<div className="kpi-grid">
{[['eq_by_os', kpis.byOs, (l) => l], ['eq_by_kind', kpis.byKind, (l) => t(`eq_kind_${l}`)], ['eq_top_software', kpis.topSoftware, (l) => l]].map(([title, rows, fmt]) => (
<div key={title} className="kpi-card">
<div className="kpi-label" style={{ marginBottom: 6 }}>{t(title)}</div>
{rows.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
{rows.map((r) => (
<div key={r.label} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '.8rem', padding: '2px 0' }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginRight: 8 }}>{fmt(r.label)}</span><strong>{r.value}</strong>
</div>
))}
</div>
))}
</div>
</>
);
}
function DevicesTab() {
const { t } = useUi();
const [search, setSearch] = useState('');
const [kind, setKind] = useState('');
const [status, setStatus] = useState('active');
const [selected, setSelected] = useState(null); // { id, view: 'info' }
const { data: devices, reload } = useAsync(() => listDevices({ kind, status }), [kind, status]);
const kindOptions = [{ value: '', label: t('common_all') }, ...KINDS.map((k) => ({ value: k, label: t(`eq_kind_${k}`) }))];
const statusOptions = [
{ value: 'active', label: t('eq_status_active') }, { value: 'retired', label: t('eq_status_retired') }, { value: '', label: t('common_all') },
];
const columns = [
{ key: 'hostname', label: t('eq_hostname'), render: (r) => <strong>{dash(r.hostname)}</strong> },
{ key: 'kind', label: t('eq_kind'), render: (r) => t(`eq_kind_${r.kind}`) },
{ key: 'model', label: t('eq_manufacturer_model'), sortValue: (r) => r.model || '', render: (r) => dash([r.manufacturer, r.model].filter(Boolean).join(' ')) },
{ key: 'os_name', label: t('eq_os'), render: (r) => dash(r.os_name) },
{ key: 'ip_main', label: t('eq_ip'), render: (r) => dash(r.ip_main) },
{
key: 'actions', label: '', sortable: false,
render: (r) => (
<span style={{ display: 'flex', gap: 6 }} onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="standard" icon={Info} onClick={() => setSelected({ id: r.id, view: 'info' })}>{t('eq_btn_info')}</Button>
</span>
),
},
];
if (!devices) return <div className="text-muted">{t('common_loading')}</div>;
return (
<>
<div className="toolbar">
<div className="toolbar-left">
<div style={{ width: 170 }}><ComboBox options={kindOptions} value={kind} onChange={setKind} /></div>
<div style={{ width: 150 }}><ComboBox options={statusOptions} value={status} onChange={setStatus} /></div>
</div>
<SearchInput value={search} onChange={setSearch} />
</div>
{devices.length === 0 && !search ? (
<div className="text-muted">{t('eq_empty')}</div>
) : (
<ListView
columns={columns} rows={devices} searchValue={search} getRowKey={(r) => r.id}
getSearchHaystack={(r) => [r.hostname, r.serial, r.model, r.manufacturer, r.ip_main, r.mac_main, r.last_user, r.assigned_user_name, r.os_name].join(' ')}
onRowClick={(r) => setSelected({ id: r.id, view: 'info' })} initialSort={{ key: 'hostname', dir: 1 }}
/>
)}
<DetailPopup open={selected?.view === 'info'} title={t('eq_detail')} onClose={() => setSelected(null)} width={860} height={680}>
{selected?.view === 'info' && <DeviceDetail key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} />}
</DetailPopup>
</>
);
}
function SoftwareTab() {
const { t } = useUi();
const [search, setSearch] = useState('');
const [selected, setSelected] = useState(null);
const [showComponents, setShowComponents] = useState(false);
const { data: rows } = useAsync(() => listSoftware(showComponents), [showComponents]);
const { data: installs } = useAsync(() => (selected ? listSoftwareDevices(selected) : Promise.resolve([])), [selected]);
if (!rows) return <div className="text-muted">{t('common_loading')}</div>;
const columns = [
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
{ key: 'version_count', label: t('eq_versions') },
{ key: 'device_count', label: t('eq_installed_on') },
];
return (
<>
<div className="toolbar">
<label className="checkbox-row">
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
{t('eq_show_components')}
</label>
<SearchInput value={search} onChange={setSearch} />
</div>
<ListView
columns={columns} rows={rows} searchValue={search} getRowKey={(r) => r.name}
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''}`} onRowClick={(r) => setSelected(r.name)}
initialSort={{ key: 'device_count', dir: -1 }}
/>
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
{ key: 'hostname', label: t('eq_hostname') }, { key: 'version', label: t('eq_version') }, { key: 'last_user', label: t('eq_last_user') },
]} />
</DetailPopup>
</>
);
}
function AgentsTab() {
const { t, pushToast, openDialog } = useUi();
const runAction = useAction();
const { data: tokens, reload } = useAsync(listInventoryTokens, []);
const [label, setLabel] = useState('');
const [created, setCreated] = useState(null); // { label, token } mostrado UNA vez
const serverUrl = `${window.location.origin}/api/inventory`;
function create() {
runAction(async () => {
const res = await createInventoryToken(label.trim());
setLabel('');
setCreated(res);
await reload();
});
}
function copy(text) {
navigator.clipboard?.writeText(text).then(() => pushToast({ tone: 'success', message: t('eq_copied') })).catch(() => {});
}
function confirmDelete(tk) {
openDialog({
tone: 'warning', title: t('common_confirm_delete_title'), message: t('eq_token_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await deleteInventoryToken(tk.id); await reload(); }) },
],
});
}
if (!tokens) return <div className="text-muted">{t('common_loading')}</div>;
return (
<>
<div className="kpi-card" style={{ marginBottom: 16 }}>
<div className="kpi-label" style={{ marginBottom: 6 }}>{t('eq_agent_setup')}</div>
<div style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('eq_agent_setup_hint')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<code style={{ flex: 1, wordBreak: 'break-all' }}>server = {serverUrl}</code>
<button className="icon-btn" onClick={() => copy(serverUrl)}><Copy size={15} /></button>
</div>
<div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_agent_setup_auth')}</div>
</div>
<div className="toolbar">
<div className="toolbar-left">
<input className="input" style={{ width: 260 }} placeholder={t('eq_token_label')} value={label} onChange={(e) => setLabel(e.target.value)} />
<Button variant="accent" icon={Plus} onClick={create} disabled={!label.trim()}>{t('eq_token_new')}</Button>
</div>
</div>
{created && (
<div className="kpi-card" style={{ marginBottom: 16, borderColor: 'rgb(var(--accent))' }}>
<div className="kpi-label">{t('eq_token_created')} <strong>{created.label}</strong></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '8px 0' }}>
<code style={{ flex: 1, wordBreak: 'break-all' }}>{created.token}</code>
<button className="icon-btn" onClick={() => copy(created.token)}><Copy size={15} /></button>
</div>
<div style={{ fontSize: '.75rem' }} className="text-muted">{t('eq_token_once')}</div>
<div style={{ marginTop: 8 }}><Button variant="standard" onClick={() => setCreated(null)}>{t('common_close')}</Button></div>
</div>
)}
<ListView
columns={[
{ key: 'label', label: t('eq_token_label'), render: (r) => <strong>{r.label}</strong> },
{ key: 'is_active', label: t('common_status'), render: (r) => <Badge color={r.is_active ? 'success' : 'muted'}>{r.is_active ? t('common_active') : t('common_inactive')}</Badge> },
{ key: 'last_used_at', label: t('eq_token_last_used'), render: (r) => fmtDate(r.last_used_at) },
{ key: 'created_by_name', label: t('eq_token_created_by'), render: (r) => dash(r.created_by_name) },
{
key: 'actions', label: '', sortable: false,
render: (r) => (
<span style={{ display: 'flex', gap: 4 }} onClick={(e) => e.stopPropagation()}>
<button className="icon-btn" title={r.is_active ? t('eq_token_disable') : t('eq_token_enable')}
onClick={() => runAction(async () => { await setInventoryTokenActive(r.id, !r.is_active); await reload(); })}><Power size={15} /></button>
<button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(r)}><Trash2 size={15} /></button>
</span>
),
},
]}
rows={tokens} getRowKey={(r) => r.id}
/>
</>
);
}
export default function EquiposPage() {
const { t } = useUi();
const { user } = useAuth();
const canTokens = ['superadmin', 'admin'].includes(user?.roleCode);
const [tab, setTab] = useState('summary');
const tabs = [
{ key: 'summary', label: t('eq_tab_summary') },
{ key: 'devices', label: t('eq_tab_devices') },
{ key: 'software', label: t('eq_tab_software_all') },
...(canTokens ? [{ key: 'agents', label: t('eq_tab_agents') }] : []),
];
return (
<div>
<h2 className="page-title mt-0">{t('nav_devices')}</h2>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
<div style={{ marginTop: 14 }}>
{tab === 'summary' && <SummaryTab />}
{tab === 'devices' && <DevicesTab />}
{tab === 'software' && <SoftwareTab />}
{tab === 'agents' && canTokens && <AgentsTab />}
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { api } from './api.js';
export const listDevices = (params = {}) => {
const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
return api.get(`/devices${qs ? `?${qs}` : ''}`);
};
export const getDevice = (id) => api.get(`/devices/${id}`);
export const updateDevice = (id, data) => api.put(`/devices/${id}`, data);
export const getDevicesDashboard = () => api.get('/devices/dashboard');
export const listSoftware = (all) => api.get(`/devices/software${all ? '?all=1' : ''}`);
export const listSoftwareDevices = (name) => api.get(`/devices/software/devices?name=${encodeURIComponent(name)}`);
export const listInventoryTokens = () => api.get('/devices/tokens');
export const createInventoryToken = (label) => api.post('/devices/tokens', { label });
export const setInventoryTokenActive = (id, isActive) => api.patch(`/devices/tokens/${id}`, { isActive });
export const deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`);