feat: version 2.15.0 -- instalador del agente GLPI, filtro de componentes y token recuperable
Instalador .bat generado en el navegador, casilla para ocultar actualizaciones/drivers/componentes en Software, buscador por pestaña en el detalle, GUIDs como componentes y tokens de ingesta cifrados (migraciones 024 y 025). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -127,7 +127,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
|||||||
- `forgot-password` responde SIEMPRE genérico (exista o no el correo); el envío de correo nunca
|
- `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).
|
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
|
- 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.
|
(`inventory_tokens`: hash para validar + copia cifrada AES-256-GCM, revelable solo a admin/superadmin con auditoría); 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
|
- 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).
|
loguean en la consola del backend en vez de enviarse (nunca rompe el flujo).
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,7 @@
|
|||||||
- `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` ·
|
- `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/software/devices?name=` — software transversal.
|
||||||
- `GET /api/devices/:id/raw` — último inventario crudo del agente.
|
- `GET /api/devices/:id/raw` — último inventario crudo del agente.
|
||||||
|
- `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`.
|
||||||
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
|
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
|
||||||
(el token en claro solo se devuelve en el `POST`).
|
(el token en claro solo se devuelve en el `POST`).
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -211,4 +211,5 @@ en producción.
|
|||||||
y `department_id` son datos manuales (el agente nunca los toca). Hijos con `ON DELETE CASCADE`.
|
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
|
`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
|
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.
|
software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda el hash (`token_hash`, valida la ingesta) y el token cifrado
|
||||||
|
(`token_enc`, migración `025`, AES-256-GCM con `ENCRYPTION_KEY`) para regenerar el instalador.
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,6 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# Changelog - project.linkedcorp.net
|
||||||
|
|
||||||
## [2.15.0] - 2026-10-19
|
## [2.15.0] - 2026-10-20
|
||||||
### Added
|
### Added
|
||||||
- Menú **Equipos**: inventario de TI alimentado por el agente GLPI oficial (sin servidor GLPI).
|
- 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
|
Ingesta `POST /api/inventory` (JSON/XML, gzip/zlib) con token de instalación, upsert por
|
||||||
@@ -10,6 +10,12 @@
|
|||||||
- Equipos → Software (general y por equipo): casilla "Mostrar actualizaciones, drivers y componentes
|
- 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
|
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.
|
`is_component` a `device_software`; se decide por `system_category` del agente y por nombre.
|
||||||
|
- Equipos → Agentes: "Instalador para Windows": descarga un `.bat` (generado en el navegador, con
|
||||||
|
servidor y token ya puestos) que baja el MSI oficial del agente GLPI 1.21 y lo instala en silencio
|
||||||
|
con `SERVER/USER/PASSWORD/TAG`. Si el .msi está junto al .bat, no descarga.
|
||||||
|
- Tokens de ingesta recuperables: se guardan también cifrados (AES-256-GCM, migración `025`) y cada
|
||||||
|
token de la lista tiene un botón para descargar el instalador sin volver a pegarlo (queda en
|
||||||
|
auditoría; los tokens creados antes de esta versión no son recuperables).
|
||||||
- Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en
|
- Dependencia backend: `fast-xml-parser`. Diseño traído de linkedtrack (que sigue aparte) en
|
||||||
`docs/ARQUITECTURA-INVENTARIO.md`.
|
`docs/ARQUITECTURA-INVENTARIO.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Token de ingesta recuperable (cifrado AES-256-GCM con ENCRYPTION_KEY, igual que la clave SMTP)
|
||||||
|
-- para poder generar el instalador del agente sin volver a pegar el token. token_hash sigue siendo
|
||||||
|
-- lo que valida la ingesta. Tokens creados antes de esta migracion quedan con token_enc NULL.
|
||||||
|
ALTER TABLE inventory_tokens ADD COLUMN IF NOT EXISTS token_enc TEXT NULL;
|
||||||
@@ -15,5 +15,6 @@ export const getRaw = asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
export const getTokens = asyncHandler(async (req, res) => ok(res, await service.listTokens()));
|
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 postToken = asyncHandler(async (req, res) => ok(res, await service.createToken(req.body.label, req.currentUser), 201));
|
||||||
|
export const getTokenSecret = asyncHandler(async (req, res) => ok(res, await service.revealToken(Number(req.params.id), req.currentUser)));
|
||||||
export const patchToken = asyncHandler(async (req, res) => ok(res, await service.setTokenActive(Number(req.params.id), !!req.body.isActive, req.currentUser)));
|
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)));
|
export const deleteToken = asyncHandler(async (req, res) => ok(res, await service.removeToken(Number(req.params.id), req.currentUser)));
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const COMPONENT_NAME = new RegExp([
|
|||||||
'windowsappruntime', 'ui xaml', 'advertising sdk', 'vclibs', 'driver', 'controlador', 'firmware', 'chipset',
|
'windowsappruntime', 'ui xaml', 'advertising sdk', 'vclibs', 'driver', 'controlador', 'firmware', 'chipset',
|
||||||
'management engine', 'rapid storage', 'serial io', 'hardware accelerated', 'compatibility fix', 'subsystem for linux',
|
'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',
|
'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)',
|
'^microsoftwindows\\.', '^[{(]?[0-9a-f]{8}-[0-9a-f]{4}-', '^windows sdk', 'software development kit.*(windows|arm|x64|x86)',
|
||||||
].join('|'), 'i');
|
].join('|'), 'i');
|
||||||
function isComponent(name, category) {
|
function isComponent(name, category) {
|
||||||
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
|
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
|
||||||
|
|||||||
@@ -27,17 +27,22 @@ export const touchToken = (id) => pool.query('UPDATE inventory_tokens SET last_u
|
|||||||
|
|
||||||
export async function listTokens() {
|
export async function listTokens() {
|
||||||
const [rows] = await pool.query(
|
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
|
`SELECT t.id, t.label, t.is_active, t.last_used_at, t.created_at, u.name AS created_by_name, (t.token_enc IS NOT NULL) AS has_secret
|
||||||
FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC`
|
FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC`
|
||||||
);
|
);
|
||||||
return rows;
|
return rows.map((r) => ({ ...r, has_secret: !!r.has_secret }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createToken(label, hash, userId) {
|
export async function createToken(label, hash, enc, userId) {
|
||||||
const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, created_by) VALUES (?, ?, ?)', [label, hash, userId]);
|
const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, token_enc, created_by) VALUES (?, ?, ?, ?)', [label, hash, enc, userId]);
|
||||||
return r.insertId;
|
return r.insertId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findTokenSecret(id) {
|
||||||
|
const [[row]] = await pool.query('SELECT token_enc FROM inventory_tokens WHERE id = ?', [id]);
|
||||||
|
return row?.token_enc || null;
|
||||||
|
}
|
||||||
|
|
||||||
export const setTokenActive = (id, active) => pool.query('UPDATE inventory_tokens SET is_active = ? WHERE id = ?', [active ? 1 : 0, id]);
|
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]);
|
export const deleteToken = (id) => pool.query('DELETE FROM inventory_tokens WHERE id = ?', [id]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
|||||||
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
|
import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
|
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
|
||||||
getTokens, postToken, patchToken, deleteToken,
|
getTokens, postToken, patchToken, deleteToken, getTokenSecret,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -17,6 +17,7 @@ router.get('/software', getSoftware);
|
|||||||
router.get('/software/devices', getSoftwareDevices);
|
router.get('/software/devices', getSoftwareDevices);
|
||||||
router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens);
|
router.get('/tokens', requireRole(...CAN_MANAGE_TOKENS), getTokens);
|
||||||
router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken);
|
router.post('/tokens', requireRole(...CAN_MANAGE_TOKENS), postToken);
|
||||||
|
router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE_TOKENS), getTokenSecret);
|
||||||
router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken);
|
router.patch('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), patchToken);
|
||||||
router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken);
|
router.delete('/tokens/:id', requireRole(...CAN_MANAGE_TOKENS), deleteToken);
|
||||||
router.get('/', getDevices);
|
router.get('/', getDevices);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
|
||||||
const STATUSES = ['active', 'retired'];
|
const STATUSES = ['active', 'retired'];
|
||||||
@@ -57,11 +58,19 @@ export async function createToken(label, actor) {
|
|||||||
const clean = String(label || '').trim();
|
const clean = String(label || '').trim();
|
||||||
if (!clean) throw new HttpError(400, 'La etiqueta es obligatoria');
|
if (!clean) throw new HttpError(400, 'La etiqueta es obligatoria');
|
||||||
const token = crypto.randomBytes(24).toString('hex');
|
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);
|
const id = await repo.createToken(clean.slice(0, 120), crypto.createHash('sha256').update(token).digest('hex'), encrypt(token), actor.id);
|
||||||
await logAudit({ userId: actor.id, action: 'create', entityType: 'inventory_token', entityId: id, detail: { label: clean } });
|
await logAudit({ userId: actor.id, action: 'create', entityType: 'inventory_token', entityId: id, detail: { label: clean } });
|
||||||
return { id, label: clean, token };
|
return { id, label: clean, token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Devuelve el token en claro a admin/superadmin para armar el instalador. Queda en auditoria.
|
||||||
|
export async function revealToken(id, actor) {
|
||||||
|
const enc = await repo.findTokenSecret(id);
|
||||||
|
if (!enc) throw new HttpError(404, 'Este token se creo antes de poder recuperarse: genera uno nuevo');
|
||||||
|
await logAudit({ userId: actor.id, action: 'reveal', entityType: 'inventory_token', entityId: id });
|
||||||
|
return { token: decrypt(enc) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function setTokenActive(id, active, actor) {
|
export async function setTokenActive(id, active, actor) {
|
||||||
await repo.setTokenActive(id, active);
|
await repo.setTokenActive(id, active);
|
||||||
await logAudit({ userId: actor.id, action: active ? 'enable' : 'disable', entityType: 'inventory_token', entityId: id });
|
await logAudit({ userId: actor.id, action: active ? 'enable' : 'disable', entityType: 'inventory_token', entityId: id });
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ batería) se guarda en `devices.extra_json`.
|
|||||||
3. Forzar un envío: `glpi-agent --force` (Linux) / "Forzar inventario" en la página del agente
|
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.
|
(`http://localhost:62354`) en Windows. El equipo aparece en **Equipos** al primer POST.
|
||||||
|
|
||||||
|
## Instalador con un clic (Windows)
|
||||||
|
|
||||||
|
Equipos → Agentes → "Instalador para Windows": genera `instalar-glpi-agent.bat` en el navegador con
|
||||||
|
`SERVER=<origen>/api/inventory`, `USER=project`, `PASSWORD=<token>` y `TAG` opcional. El .bat se
|
||||||
|
auto-eleva, descarga `GLPI-Agent-<ver>-x64.msi` de GitHub (versión fijada en
|
||||||
|
`frontend/src/pages/Equipos/agentInstaller.js#GLPI_AGENT_VERSION`; o usa el .msi que esté junto al
|
||||||
|
.bat), instala con `msiexec /qn` y `RUNNOW=1`, y verifica que el servicio `glpi-agent` corra. El
|
||||||
|
token solo se conoce al crearlo, por eso el .bat se genera en la pantalla y no en el servidor.
|
||||||
|
Linux: usar el instalador oficial `glpi-agent-<ver>-linux-installer.pl --server=… --user=… --password=…`.
|
||||||
|
|
||||||
## Pendientes / por verificar con un agente real
|
## Pendientes / por verificar con un agente real
|
||||||
|
|
||||||
- Confirmar que el agente acepta la respuesta del handshake tal cual (si no, ajustar `ingest.js`).
|
- Confirmar que el agente acepta la respuesta del handshake tal cual (si no, ajustar `ingest.js`).
|
||||||
|
|||||||
@@ -191,6 +191,14 @@ export const messages = {
|
|||||||
nav_notes: 'Notas',
|
nav_notes: 'Notas',
|
||||||
nav_passwords: 'Contraseñas',
|
nav_passwords: 'Contraseñas',
|
||||||
nav_devices: 'Equipos',
|
nav_devices: 'Equipos',
|
||||||
|
eq_installer_title: 'Instalador para Windows',
|
||||||
|
eq_installer_row_hint: 'También puedes descargarlo con el ícono de cada token de la lista.',
|
||||||
|
eq_token_no_secret: 'Token anterior: no se puede recuperar, genera uno nuevo',
|
||||||
|
eq_installer_hint: 'Descarga un instalador (.bat) con el servidor y el token ya configurados: se ejecuta con doble clic en el equipo, sin configurar nada más.',
|
||||||
|
eq_installer_token: 'Token (se llena solo al generar uno)',
|
||||||
|
eq_installer_tag: 'Etiqueta opcional (ej. Contabilidad)',
|
||||||
|
eq_installer_download: 'Descargar instalador',
|
||||||
|
eq_installer_note: 'El .bat contiene el token: compártelo solo con quien instale. Descarga el agente oficial de GitHub; si el equipo no tiene internet, copia el .msi junto al .bat.',
|
||||||
eq_tab_summary: 'Resumen',
|
eq_tab_summary: 'Resumen',
|
||||||
eq_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema',
|
eq_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema',
|
||||||
eq_btn_info: 'Información',
|
eq_btn_info: 'Información',
|
||||||
@@ -647,6 +655,14 @@ export const messages = {
|
|||||||
nav_notes: 'Notes',
|
nav_notes: 'Notes',
|
||||||
nav_passwords: 'Passwords',
|
nav_passwords: 'Passwords',
|
||||||
nav_devices: 'Devices',
|
nav_devices: 'Devices',
|
||||||
|
eq_installer_title: 'Windows installer',
|
||||||
|
eq_installer_row_hint: 'You can also download it with the icon on each token in the list.',
|
||||||
|
eq_token_no_secret: 'Older token: cannot be recovered, generate a new one',
|
||||||
|
eq_installer_hint: 'Download an installer (.bat) with the server and token already set: double-click it on the machine, nothing else to configure.',
|
||||||
|
eq_installer_token: 'Token (filled in when you generate one)',
|
||||||
|
eq_installer_tag: 'Optional tag (e.g. Accounting)',
|
||||||
|
eq_installer_download: 'Download installer',
|
||||||
|
eq_installer_note: 'The .bat contains the token: share it only with whoever installs. It downloads the official agent from GitHub; if the machine has no internet, put the .msi next to the .bat.',
|
||||||
eq_tab_summary: 'Summary',
|
eq_tab_summary: 'Summary',
|
||||||
eq_show_components: 'Show updates, drivers and system components',
|
eq_show_components: 'Show updates, drivers and system components',
|
||||||
eq_btn_info: 'Information',
|
eq_btn_info: 'Information',
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Pencil, Save, Plus, Trash2, Power, Copy, Info } from 'lucide-react';
|
import { Pencil, Save, Plus, Trash2, Power, Copy, Info, Download } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import {
|
import {
|
||||||
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
|
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
|
||||||
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken,
|
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
|
||||||
} from '../../services/devices.js';
|
} from '../../services/devices.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
import { listDepartments } from '../../services/departments.js';
|
import { listDepartments } from '../../services/departments.js';
|
||||||
@@ -18,6 +18,7 @@ import { ListView } from '../../components/ListView.jsx';
|
|||||||
import { Tabs } from '../../components/Tabs.jsx';
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { KpiCard } from '../../components/KpiCard.jsx';
|
import { KpiCard } from '../../components/KpiCard.jsx';
|
||||||
|
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
|
||||||
|
|
||||||
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
|
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
|
||||||
const fmtDate = (v) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—');
|
const fmtDate = (v) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—');
|
||||||
@@ -381,12 +382,26 @@ function AgentsTab() {
|
|||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [created, setCreated] = useState(null); // { label, token } mostrado UNA vez
|
const [created, setCreated] = useState(null); // { label, token } mostrado UNA vez
|
||||||
const serverUrl = `${window.location.origin}/api/inventory`;
|
const serverUrl = `${window.location.origin}/api/inventory`;
|
||||||
|
const [installToken, setInstallToken] = useState('');
|
||||||
|
const [installTag, setInstallTag] = useState('');
|
||||||
|
|
||||||
|
function downloadFromToken(tk) {
|
||||||
|
runAction(async () => {
|
||||||
|
const { token } = await getInventoryTokenSecret(tk.id);
|
||||||
|
downloadTextFile('instalar-glpi-agent.bat', buildInstallerBat({ serverUrl, token, tag: installTag }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadInstaller() {
|
||||||
|
downloadTextFile('instalar-glpi-agent.bat', buildInstallerBat({ serverUrl, token: installToken.trim(), tag: installTag }));
|
||||||
|
}
|
||||||
|
|
||||||
function create() {
|
function create() {
|
||||||
runAction(async () => {
|
runAction(async () => {
|
||||||
const res = await createInventoryToken(label.trim());
|
const res = await createInventoryToken(label.trim());
|
||||||
setLabel('');
|
setLabel('');
|
||||||
setCreated(res);
|
setCreated(res);
|
||||||
|
setInstallToken(res.token);
|
||||||
await reload();
|
await reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -418,6 +433,17 @@ function AgentsTab() {
|
|||||||
<div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_agent_setup_auth')}</div>
|
<div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_agent_setup_auth')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="kpi-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="kpi-label" style={{ marginBottom: 6 }}>{t('eq_installer_title')} (v{GLPI_AGENT_VERSION})</div>
|
||||||
|
<div style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('eq_installer_hint')} {t('eq_installer_row_hint')}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<input className="input" style={{ width: 320 }} placeholder={t('eq_installer_token')} value={installToken} onChange={(e) => setInstallToken(e.target.value)} />
|
||||||
|
<input className="input" style={{ width: 180 }} placeholder={t('eq_installer_tag')} value={installTag} onChange={(e) => setInstallTag(e.target.value)} />
|
||||||
|
<Button variant="accent" icon={Download} onClick={downloadInstaller} disabled={!installToken.trim()}>{t('eq_installer_download')}</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_installer_note')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<div className="toolbar-left">
|
<div className="toolbar-left">
|
||||||
<input className="input" style={{ width: 260 }} placeholder={t('eq_token_label')} value={label} onChange={(e) => setLabel(e.target.value)} />
|
<input className="input" style={{ width: 260 }} placeholder={t('eq_token_label')} value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||||
@@ -447,6 +473,8 @@ function AgentsTab() {
|
|||||||
key: 'actions', label: '', sortable: false,
|
key: 'actions', label: '', sortable: false,
|
||||||
render: (r) => (
|
render: (r) => (
|
||||||
<span style={{ display: 'flex', gap: 4 }} onClick={(e) => e.stopPropagation()}>
|
<span style={{ display: 'flex', gap: 4 }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="icon-btn" title={r.has_secret ? t('eq_installer_download') : t('eq_token_no_secret')} disabled={!r.has_secret}
|
||||||
|
onClick={() => downloadFromToken(r)}><Download size={15} /></button>
|
||||||
<button className="icon-btn" title={r.is_active ? t('eq_token_disable') : t('eq_token_enable')}
|
<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>
|
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>
|
<button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(r)}><Trash2 size={15} /></button>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Genera en el navegador el instalador (.bat) del agente GLPI con el servidor y el token ya
|
||||||
|
// puestos. No pasa por el backend: el token en claro solo existe en la pantalla (en BD queda su hash).
|
||||||
|
export const GLPI_AGENT_VERSION = '1.21';
|
||||||
|
|
||||||
|
export function buildInstallerBat({ serverUrl, token, tag }) {
|
||||||
|
const ver = GLPI_AGENT_VERSION;
|
||||||
|
const msiName = `GLPI-Agent-${ver}-x64.msi`;
|
||||||
|
const safeTag = String(tag || '').replace(/[^\w .-]/g, '').trim();
|
||||||
|
const lines = [
|
||||||
|
'@echo off',
|
||||||
|
'setlocal',
|
||||||
|
`title Instalador GLPI Agent ${ver} - Project`,
|
||||||
|
`rem Generado por Project (${serverUrl}). Si el .msi esta junto a este archivo se usa; si no, se descarga.`,
|
||||||
|
'net session >nul 2>&1',
|
||||||
|
'if %errorlevel% neq 0 (',
|
||||||
|
' echo Solicitando permisos de administrador...',
|
||||||
|
` powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"`,
|
||||||
|
' exit /b',
|
||||||
|
')',
|
||||||
|
`set "MSI=%~dp0${msiName}"`,
|
||||||
|
'if exist "%MSI%" goto instalar',
|
||||||
|
`set "MSI=%TEMP%\\${msiName}"`,
|
||||||
|
'if exist "%MSI%" goto instalar',
|
||||||
|
`echo Descargando GLPI Agent ${ver}...`,
|
||||||
|
`powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -UseBasicParsing 'https://github.com/glpi-project/glpi-agent/releases/download/${ver}/${msiName}' -OutFile '%MSI%'"`,
|
||||||
|
'if errorlevel 1 (',
|
||||||
|
` echo ERROR: no se pudo descargar. Copie ${msiName} junto a este archivo y ejecute de nuevo.`,
|
||||||
|
' pause',
|
||||||
|
' exit /b 1',
|
||||||
|
')',
|
||||||
|
':instalar',
|
||||||
|
'echo Instalando y configurando el agente...',
|
||||||
|
`start /wait msiexec /i "%MSI%" /qn /norestart SERVER="${serverUrl}" USER="project" PASSWORD="${token}"${safeTag ? ` TAG="${safeTag}"` : ''} RUNNOW=1`,
|
||||||
|
'set RC=%errorlevel%',
|
||||||
|
'if %RC% neq 0 if %RC% neq 3010 (',
|
||||||
|
' echo ERROR: la instalacion fallo ^(codigo %RC%^).',
|
||||||
|
' pause',
|
||||||
|
' exit /b %RC%',
|
||||||
|
')',
|
||||||
|
'echo.',
|
||||||
|
'sc query glpi-agent | find "RUNNING" >nul && (echo Listo: el agente esta instalado y en ejecucion. El equipo aparecera en Project en unos minutos.) || (echo Instalado, pero el servicio no esta en ejecucion. Revise services.msc.)',
|
||||||
|
'pause',
|
||||||
|
];
|
||||||
|
return lines.join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadTextFile(filename, content) {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type: 'application/octet-stream' }));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -14,3 +14,4 @@ export const listInventoryTokens = () => api.get('/devices/tokens');
|
|||||||
export const createInventoryToken = (label) => api.post('/devices/tokens', { label });
|
export const createInventoryToken = (label) => api.post('/devices/tokens', { label });
|
||||||
export const setInventoryTokenActive = (id, isActive) => api.patch(`/devices/tokens/${id}`, { isActive });
|
export const setInventoryTokenActive = (id, isActive) => api.patch(`/devices/tokens/${id}`, { isActive });
|
||||||
export const deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`);
|
export const deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`);
|
||||||
|
export const getInventoryTokenSecret = (id) => api.get(`/devices/tokens/${id}/secret`);
|
||||||
|
|||||||
Reference in New Issue
Block a user