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:
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
Generated
+33
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)));
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) })),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "2.14.0",
|
||||
"version": "2.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : ''}`}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
Reference in New Issue
Block a user