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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-19 19:05:00 -05:00
parent c33306a881
commit e25c2c3b35
27 changed files with 1730 additions and 11 deletions
+2
View File
@@ -21,6 +21,7 @@ import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx';
import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx';
import NotasPage from '../pages/Notas/NotasPage.jsx';
import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx';
import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
export default function App() {
@@ -45,6 +46,7 @@ export default function App() {
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
<Route path="notas" element={<NotasPage />} />
<Route path="contrasenas" element={<ContrasenasPage />} />
<Route path="equipos" element={<EquiposPage />} />
<Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} />
</Route>
+3 -2
View File
@@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound } from 'lucide-react';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAuth } from '../context/auth-context.jsx';
import { useBranding } from '../context/branding-context.jsx';
@@ -12,6 +12,7 @@ const ITEMS = [
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
{ to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' },
{ to: '/equipos', icon: Monitor, labelKey: 'nav_devices', roles: ['superadmin', 'admin', 'it_manager'] },
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
];
@@ -23,7 +24,7 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
// En celular el drawer siempre se ve completo (con etiquetas), sin importar el modo
// compacto/expandido del riel de escritorio -- son dos cosas independientes.
const compact = mode === 'compact' && !mobileOpen;
const items = ITEMS.filter((item) => !item.superadminOnly || user?.roleCode === 'superadmin');
const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.roles || item.roles.includes(user?.roleCode)));
const brandName = branding.name || t('appName');
return (
<nav className={`nav-view ${mode} ${mobileOpen ? 'mobile-open' : ''}`}>
+182
View File
@@ -190,6 +190,97 @@ export const messages = {
nav_recurring_tasks: 'Tareas recurrentes',
nav_notes: 'Notas',
nav_passwords: 'Contraseñas',
nav_devices: 'Equipos',
eq_tab_summary: 'Resumen',
eq_show_components: 'Mostrar actualizaciones, drivers y componentes del sistema',
eq_btn_info: 'Información',
eq_tab_devices: 'Equipos',
eq_tab_software_all: 'Software',
eq_tab_agents: 'Agentes',
eq_tab_general: 'General',
eq_tab_hardware: 'Hardware',
eq_tab_network: 'Red',
eq_tab_software: 'Software',
eq_tab_history: 'Historial',
eq_detail: 'Detalle del equipo',
eq_empty: 'Aún no hay equipos. Instala el agente GLPI en un equipo apuntando a este sistema (pestaña Agentes).',
eq_hostname: 'Nombre',
eq_kind: 'Tipo',
eq_kind_desktop: 'PC de escritorio',
eq_kind_laptop: 'Portátil',
eq_kind_server: 'Servidor',
eq_kind_virtual: 'Máquina virtual',
eq_kind_printer: 'Impresora',
eq_kind_network: 'Equipo de red',
eq_kind_other: 'Otro',
eq_status_active: 'Activo',
eq_status_retired: 'De baja',
eq_manufacturer_model: 'Marca y modelo',
eq_manufacturer: 'Fabricante',
eq_model: 'Modelo',
eq_serial: 'N.º de serie',
eq_os: 'Sistema operativo',
eq_domain: 'Dominio',
eq_last_user: 'Último usuario',
eq_ip: 'IP',
eq_first_seen: 'Primer reporte',
eq_last_seen: 'Último reporte',
eq_agent: 'Versión del agente',
eq_assigned_to: 'Asignado a',
eq_notes: 'Notas',
eq_cpu: 'Procesador',
eq_ram: 'Memoria RAM',
eq_disk_total: 'Almacenamiento',
eq_disk_max_used: 'volumen más lleno',
eq_memories: 'Módulos de memoria',
eq_disks: 'Discos físicos',
eq_volumes: 'Volúmenes',
eq_monitors: 'Monitores',
eq_slot: 'Ranura',
eq_capacity: 'Capacidad',
eq_type: 'Tipo',
eq_speed: 'Velocidad',
eq_mount: 'Unidad',
eq_fs: 'Sistema de archivos',
eq_free: 'Libre',
eq_adapter: 'Adaptador',
eq_mask: 'Máscara',
eq_gateway: 'Puerta de enlace',
eq_version: 'Versión',
eq_versions: 'Versiones',
eq_publisher: 'Editor',
eq_installed_on: 'Equipos',
eq_when: 'Fecha',
eq_change: 'Cambio',
eq_field: 'Campo',
eq_before: 'Antes',
eq_after: 'Después',
eq_history_empty: 'Sin cambios registrados entre reportes.',
eq_change_field: 'Dato modificado',
eq_change_disk_added: 'Disco agregado',
eq_change_disk_removed: 'Disco retirado',
eq_change_software_installed: 'Software instalado',
eq_change_software_removed: 'Software desinstalado',
eq_kpi_total: 'Equipos activos',
eq_kpi_stale: 'Sin reportar (+7 días)',
eq_kpi_disk_full: 'Disco ≥ 90% lleno',
eq_kpi_low_ram: 'RAM menor a 4 GB',
eq_by_os: 'Por sistema operativo',
eq_by_kind: 'Por tipo',
eq_top_software: 'Software más instalado',
eq_agent_setup: 'Conectar un agente GLPI',
eq_agent_setup_hint: 'En el archivo de configuración del agente (o en el instalador) usa esta dirección como servidor:',
eq_agent_setup_auth: 'Autenticación: usuario cualquiera y, como contraseña, el token generado abajo.',
eq_token_label: 'Etiqueta del token (ej. Oficina central)',
eq_token_new: 'Generar token',
eq_token_created: 'Token generado para',
eq_token_once: 'Cópialo ahora: no se vuelve a mostrar. En el sistema solo queda su huella cifrada.',
eq_token_last_used: 'Último uso',
eq_token_created_by: 'Creado por',
eq_token_enable: 'Activar',
eq_token_disable: 'Desactivar',
eq_token_delete_msg: 'Los agentes que usen este token dejarán de poder reportar. ¿Eliminarlo?',
eq_copied: 'Copiado',
vault_pin_label: 'PIN (4 números)',
vault_pin_confirm_label: 'Confirmar PIN',
vault_pin_current_label: 'PIN actual',
@@ -555,6 +646,97 @@ export const messages = {
nav_recurring_tasks: 'Recurring tasks',
nav_notes: 'Notes',
nav_passwords: 'Passwords',
nav_devices: 'Devices',
eq_tab_summary: 'Summary',
eq_show_components: 'Show updates, drivers and system components',
eq_btn_info: 'Information',
eq_tab_devices: 'Devices',
eq_tab_software_all: 'Software',
eq_tab_agents: 'Agents',
eq_tab_general: 'General',
eq_tab_hardware: 'Hardware',
eq_tab_network: 'Network',
eq_tab_software: 'Software',
eq_tab_history: 'History',
eq_detail: 'Device detail',
eq_empty: 'No devices yet. Install the GLPI agent on a machine pointing to this system (Agents tab).',
eq_hostname: 'Name',
eq_kind: 'Type',
eq_kind_desktop: 'Desktop',
eq_kind_laptop: 'Laptop',
eq_kind_server: 'Server',
eq_kind_virtual: 'Virtual machine',
eq_kind_printer: 'Printer',
eq_kind_network: 'Network device',
eq_kind_other: 'Other',
eq_status_active: 'Active',
eq_status_retired: 'Retired',
eq_manufacturer_model: 'Make and model',
eq_manufacturer: 'Manufacturer',
eq_model: 'Model',
eq_serial: 'Serial no.',
eq_os: 'Operating system',
eq_domain: 'Domain',
eq_last_user: 'Last user',
eq_ip: 'IP',
eq_first_seen: 'First report',
eq_last_seen: 'Last report',
eq_agent: 'Agent version',
eq_assigned_to: 'Assigned to',
eq_notes: 'Notes',
eq_cpu: 'Processor',
eq_ram: 'RAM',
eq_disk_total: 'Storage',
eq_disk_max_used: 'fullest volume',
eq_memories: 'Memory modules',
eq_disks: 'Physical disks',
eq_volumes: 'Volumes',
eq_monitors: 'Monitors',
eq_slot: 'Slot',
eq_capacity: 'Capacity',
eq_type: 'Type',
eq_speed: 'Speed',
eq_mount: 'Drive',
eq_fs: 'File system',
eq_free: 'Free',
eq_adapter: 'Adapter',
eq_mask: 'Mask',
eq_gateway: 'Gateway',
eq_version: 'Version',
eq_versions: 'Versions',
eq_publisher: 'Publisher',
eq_installed_on: 'Devices',
eq_when: 'Date',
eq_change: 'Change',
eq_field: 'Field',
eq_before: 'Before',
eq_after: 'After',
eq_history_empty: 'No changes recorded between reports.',
eq_change_field: 'Field changed',
eq_change_disk_added: 'Disk added',
eq_change_disk_removed: 'Disk removed',
eq_change_software_installed: 'Software installed',
eq_change_software_removed: 'Software removed',
eq_kpi_total: 'Active devices',
eq_kpi_stale: 'Not reporting (+7 days)',
eq_kpi_disk_full: 'Disk ≥ 90% full',
eq_kpi_low_ram: 'RAM under 4 GB',
eq_by_os: 'By operating system',
eq_by_kind: 'By type',
eq_top_software: 'Most installed software',
eq_agent_setup: 'Connect a GLPI agent',
eq_agent_setup_hint: 'In the agent configuration file (or installer) use this address as the server:',
eq_agent_setup_auth: 'Authentication: any user name and, as password, the token generated below.',
eq_token_label: 'Token label (e.g. Head office)',
eq_token_new: 'Generate token',
eq_token_created: 'Token generated for',
eq_token_once: 'Copy it now: it is not shown again. Only its hash is stored.',
eq_token_last_used: 'Last used',
eq_token_created_by: 'Created by',
eq_token_enable: 'Enable',
eq_token_disable: 'Disable',
eq_token_delete_msg: 'Agents using this token will no longer be able to report. Delete it?',
eq_copied: 'Copied',
vault_pin_label: 'PIN (4 digits)',
vault_pin_confirm_label: 'Confirm PIN',
vault_pin_current_label: 'Current PIN',
+486
View File
@@ -0,0 +1,486 @@
import { useState } from 'react';
import { Pencil, Save, Plus, Trash2, Power, Copy, Info } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import {
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken,
} from '../../services/devices.js';
import { listUsers } from '../../services/users.js';
import { listDepartments } from '../../services/departments.js';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { SearchInput } from '../../components/SearchInput.jsx';
import { ListView } from '../../components/ListView.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { Badge } from '../../components/Badge.jsx';
import { KpiCard } from '../../components/KpiCard.jsx';
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
const fmtDate = (v) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—');
const gb = (mb) => (mb ? `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} GB` : '—');
const dash = (v) => (v === null || v === undefined || v === '' ? '—' : v);
const daysAgo = (v) => (v ? (Date.now() - new Date(String(v).replace(' ', 'T')).getTime()) / 86400000 : Infinity);
function Row({ label, children }) {
return (
<div style={{ display: 'flex', gap: 12, padding: '5px 0', fontSize: '.82rem' }}>
<span className="text-muted" style={{ width: 150, flexShrink: 0 }}>{label}</span>
<span style={{ flex: 1, wordBreak: 'break-word' }}>{children}</span>
</div>
);
}
function SimpleTable({ columns, rows, empty }) {
if (!rows?.length) return <div className="text-muted">{empty}</div>;
return (
<div className="list-card">
<table className="list-table">
<thead><tr>{columns.map((c) => <th key={c.key}>{c.label}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={r.id ?? i}>{columns.map((c) => <td key={c.key}>{c.render ? c.render(r) : dash(r[c.key])}</td>)}</tr>
))}
</tbody>
</table>
</div>
);
}
function DeviceDetail({ id, onClose, onChanged }) {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
const { data: users } = useAsync(listUsers, []);
const { data: departments } = useAsync(listDepartments, []);
const [tab, setTab] = useState('general');
const [searches, setSearches] = useState({});
const [showComponents, setShowComponents] = useState(false);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({});
const flat = (list) => (list || []).flatMap((d) => [d, ...(d.children ? flat(d.children) : [])]);
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
function startEdit() {
setForm({
status: device.status,
assignedUserId: device.assigned_user_id || '',
departmentId: device.department_id || '',
notes: device.notes || '',
});
setEditing(true);
}
function save() {
const changed = form.status !== device.status
|| Number(form.assignedUserId || 0) !== Number(device.assigned_user_id || 0)
|| Number(form.departmentId || 0) !== Number(device.department_id || 0)
|| form.notes !== (device.notes || '');
if (!changed) { setEditing(false); return; }
runAction(async () => {
await updateDevice(id, {
status: form.status,
assignedUserId: form.assignedUserId ? Number(form.assignedUserId) : null,
departmentId: form.departmentId ? Number(form.departmentId) : null,
notes: form.notes,
});
setEditing(false);
await reload();
onChanged();
pushToast({ tone: 'success', message: t('common_saved') });
});
}
if (!device) return <div className="text-muted">{t('common_loading')}</div>;
const tabs = [
{ key: 'general', label: t('eq_tab_general') },
{ key: 'hardware', label: t('eq_tab_hardware') },
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
{ key: 'software', label: t('eq_tab_software'), count: device.software.filter((r) => showComponents || !r.is_component).length },
{ key: 'history', label: t('eq_tab_history'), count: device.changes.length },
];
// Un buscador por pestaña (su texto se conserva al cambiar de pestaña y volver).
const q = (searches[tab] || '').trim().toLowerCase();
const hit = (...values) => !q || values.join(' ').toLowerCase().includes(q);
const emptyText = q ? t('common_search_no_results') : t('common_empty');
const only = (rows, fn) => rows.filter((r) => hit(...fn(r)));
const showRows = (list) => list.filter(([label, , text]) => hit(label, text)).map(([label, node]) => <Row key={label} label={label}>{node}</Row>);
const val = (v) => dash(v);
const infoRows = [
[t('eq_hostname'), val(device.hostname), device.hostname],
[t('eq_kind'), t(`eq_kind_${device.kind}`), t(`eq_kind_${device.kind}`)],
[t('eq_manufacturer_model'), val([device.manufacturer, device.model].filter(Boolean).join(' ')), `${device.manufacturer || ''} ${device.model || ''}`],
[t('eq_serial'), val(device.serial), device.serial],
[t('eq_os'), val([device.os_name, device.os_version].filter(Boolean).join(' ')), `${device.os_name || ''} ${device.os_version || ''}`],
[t('eq_domain'), val(device.domain), device.domain],
[t('eq_last_user'), val(device.last_user), device.last_user],
[t('eq_ip'), val(device.ip_main), device.ip_main],
[t('eq_first_seen'), fmtDate(device.first_seen), fmtDate(device.first_seen)],
[t('eq_last_seen'), fmtDate(device.last_seen), fmtDate(device.last_seen)],
[t('eq_agent'), val(device.agent_version), device.agent_version],
];
const manualRows = [
[t('common_status'), <Badge color={device.status === 'active' ? 'success' : 'muted'}>{t(`eq_status_${device.status}`)}</Badge>, t(`eq_status_${device.status}`)],
[t('eq_assigned_to'), val(device.assigned_user_name), device.assigned_user_name],
[t('common_department'), val(device.department_name), device.department_name],
[t('eq_notes'), <span style={{ whiteSpace: 'pre-wrap' }}>{val(device.notes)}</span>, device.notes],
];
const cpuText = device.cpus.map((c) => `${c.name || ''}${c.cores ? ` · ${c.cores}c/${c.threads || '?'}t` : ''}`).join(' | ');
const hwRows = [
[t('eq_cpu'), val(cpuText), cpuText],
[t('eq_ram'), gb(device.ram_mb), gb(device.ram_mb)],
[t('eq_disk_total'), <>{gb(device.disk_total_mb)} {device.max_disk_used_pct != null && <span className="text-muted">· {t('eq_disk_max_used')} {device.max_disk_used_pct}%</span>}</>, `${gb(device.disk_total_mb)} ${device.max_disk_used_pct ?? ''}`],
];
const memories = only(device.memories, (r) => [r.slot, gb(r.capacity_mb), r.type, r.speed, r.manufacturer]);
const disks = only(device.disks, (r) => [r.model, r.type, gb(r.size_mb), r.serial]);
const volumes = only(device.volumes, (r) => [r.mount, r.filesystem, gb(r.total_mb), gb(r.free_mb)]);
const monitors = only(device.monitors, (r) => [r.manufacturer, r.model, r.serial]);
const hwSection = (title, rows, columns) => (!q || rows.length > 0) && (
<>
<h4>{title}</h4>
<SimpleTable empty={emptyText} rows={rows} columns={columns} />
</>
);
const networks = only(device.networks, (r) => [r.description, r.ip, r.mask, r.gateway, r.mac, r.status]);
const software = only(device.software.filter((r) => showComponents || !r.is_component), (r) => [r.name, r.version, r.publisher]);
const changes = only(device.changes, (r) => [fmtDate(r.created_at), t(`eq_change_${r.change_type}`), r.field, r.old_value, r.new_value]);
return (
<>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
<div style={{ marginTop: 12 }}>
<div className="toolbar" style={{ marginBottom: 10 }}>
{tab === 'software' ? (
<label className="checkbox-row">
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
{t('eq_show_components')}
</label>
) : <div />}
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
</div>
{tab === 'general' && (
<>
{showRows(infoRows)}
<hr style={{ margin: '12px 0', border: 0, borderTop: '1px solid rgb(var(--line))' }} />
{!editing ? (
<>
{showRows(manualRows)}
{q && showRows(infoRows).length + showRows(manualRows).length === 0 && <div className="text-muted">{t('common_search_no_results')}</div>}
<div style={{ marginTop: 10 }}><Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button></div>
</>
) : (
<>
<Field label={t('common_status')}>
<ComboBox
options={[{ value: 'active', label: t('eq_status_active') }, { value: 'retired', label: t('eq_status_retired') }]}
value={form.status} onChange={(v) => setForm({ ...form, status: v })}
/>
</Field>
<Field label={t('eq_assigned_to')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.assignedUserId} onChange={(v) => setForm({ ...form, assignedUserId: v })} />
</Field>
<Field label={t('common_department')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm({ ...form, departmentId: v })} />
</Field>
<Field label={t('eq_notes')}>
<textarea className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
</div>
</>
)}
</>
)}
{tab === 'hardware' && (
<>
{showRows(hwRows)}
{hwSection(t('eq_memories'), memories, [
{ key: 'slot', label: t('eq_slot') }, { key: 'capacity_mb', label: t('eq_capacity'), render: (r) => gb(r.capacity_mb) },
{ key: 'type', label: t('eq_type') }, { key: 'speed', label: t('eq_speed') }, { key: 'manufacturer', label: t('eq_manufacturer') },
])}
{hwSection(t('eq_disks'), disks, [
{ key: 'model', label: t('eq_model') }, { key: 'type', label: t('eq_type') },
{ key: 'size_mb', label: t('eq_capacity'), render: (r) => gb(r.size_mb) }, { key: 'serial', label: t('eq_serial') },
])}
{hwSection(t('eq_volumes'), volumes, [
{ key: 'mount', label: t('eq_mount') }, { key: 'filesystem', label: t('eq_fs') },
{ key: 'total_mb', label: t('eq_capacity'), render: (r) => gb(r.total_mb) }, { key: 'free_mb', label: t('eq_free'), render: (r) => gb(r.free_mb) },
])}
{hwSection(t('eq_monitors'), monitors, [
{ key: 'manufacturer', label: t('eq_manufacturer') }, { key: 'model', label: t('eq_model') }, { key: 'serial', label: t('eq_serial') },
])}
{q && showRows(hwRows).length + memories.length + disks.length + volumes.length + monitors.length === 0 && <div className="text-muted">{t('common_search_no_results')}</div>}
</>
)}
{tab === 'network' && (
<SimpleTable empty={emptyText} rows={networks} columns={[
{ key: 'description', label: t('eq_adapter') }, { key: 'ip', label: 'IP' }, { key: 'mask', label: t('eq_mask') },
{ key: 'gateway', label: t('eq_gateway') }, { key: 'mac', label: 'MAC' }, { key: 'status', label: t('common_status') },
]} />
)}
{tab === 'software' && (
<SimpleTable empty={emptyText} rows={software} columns={[
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') }, { key: 'publisher', label: t('eq_publisher') },
]} />
)}
{tab === 'history' && (
<SimpleTable empty={q ? emptyText : t('eq_history_empty')} rows={changes} columns={[
{ key: 'created_at', label: t('eq_when'), render: (r) => fmtDate(r.created_at) },
{ key: 'change_type', label: t('eq_change'), render: (r) => t(`eq_change_${r.change_type}`) },
{ key: 'field', label: t('eq_field') },
{ key: 'old_value', label: t('eq_before') }, { key: 'new_value', label: t('eq_after') },
]} />
)}
</div>
</>
);
}
function SummaryTab() {
const { t } = useUi();
const { data: kpis } = useAsync(getDevicesDashboard, []);
if (!kpis) return <div className="text-muted">{t('common_loading')}</div>;
return (
<>
<div className="kpi-grid">
<KpiCard label={t('eq_kpi_total')} value={kpis.total} />
<KpiCard label={t('eq_kpi_stale')} value={kpis.stale} />
<KpiCard label={t('eq_kpi_disk_full')} value={kpis.diskFull} />
<KpiCard label={t('eq_kpi_low_ram')} value={kpis.lowRam} />
</div>
<div className="kpi-grid">
{[['eq_by_os', kpis.byOs, (l) => l], ['eq_by_kind', kpis.byKind, (l) => t(`eq_kind_${l}`)], ['eq_top_software', kpis.topSoftware, (l) => l]].map(([title, rows, fmt]) => (
<div key={title} className="kpi-card">
<div className="kpi-label" style={{ marginBottom: 6 }}>{t(title)}</div>
{rows.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
{rows.map((r) => (
<div key={r.label} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '.8rem', padding: '2px 0' }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginRight: 8 }}>{fmt(r.label)}</span><strong>{r.value}</strong>
</div>
))}
</div>
))}
</div>
</>
);
}
function DevicesTab() {
const { t } = useUi();
const [search, setSearch] = useState('');
const [kind, setKind] = useState('');
const [status, setStatus] = useState('active');
const [selected, setSelected] = useState(null); // { id, view: 'info' }
const { data: devices, reload } = useAsync(() => listDevices({ kind, status }), [kind, status]);
const kindOptions = [{ value: '', label: t('common_all') }, ...KINDS.map((k) => ({ value: k, label: t(`eq_kind_${k}`) }))];
const statusOptions = [
{ value: 'active', label: t('eq_status_active') }, { value: 'retired', label: t('eq_status_retired') }, { value: '', label: t('common_all') },
];
const columns = [
{ key: 'hostname', label: t('eq_hostname'), render: (r) => <strong>{dash(r.hostname)}</strong> },
{ key: 'kind', label: t('eq_kind'), render: (r) => t(`eq_kind_${r.kind}`) },
{ key: 'model', label: t('eq_manufacturer_model'), sortValue: (r) => r.model || '', render: (r) => dash([r.manufacturer, r.model].filter(Boolean).join(' ')) },
{ key: 'os_name', label: t('eq_os'), render: (r) => dash(r.os_name) },
{ key: 'ip_main', label: t('eq_ip'), render: (r) => dash(r.ip_main) },
{
key: 'actions', label: '', sortable: false,
render: (r) => (
<span style={{ display: 'flex', gap: 6 }} onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="standard" icon={Info} onClick={() => setSelected({ id: r.id, view: 'info' })}>{t('eq_btn_info')}</Button>
</span>
),
},
];
if (!devices) return <div className="text-muted">{t('common_loading')}</div>;
return (
<>
<div className="toolbar">
<div className="toolbar-left">
<div style={{ width: 170 }}><ComboBox options={kindOptions} value={kind} onChange={setKind} /></div>
<div style={{ width: 150 }}><ComboBox options={statusOptions} value={status} onChange={setStatus} /></div>
</div>
<SearchInput value={search} onChange={setSearch} />
</div>
{devices.length === 0 && !search ? (
<div className="text-muted">{t('eq_empty')}</div>
) : (
<ListView
columns={columns} rows={devices} searchValue={search} getRowKey={(r) => r.id}
getSearchHaystack={(r) => [r.hostname, r.serial, r.model, r.manufacturer, r.ip_main, r.mac_main, r.last_user, r.assigned_user_name, r.os_name].join(' ')}
onRowClick={(r) => setSelected({ id: r.id, view: 'info' })} initialSort={{ key: 'hostname', dir: 1 }}
/>
)}
<DetailPopup open={selected?.view === 'info'} title={t('eq_detail')} onClose={() => setSelected(null)} width={860} height={680}>
{selected?.view === 'info' && <DeviceDetail key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} />}
</DetailPopup>
</>
);
}
function SoftwareTab() {
const { t } = useUi();
const [search, setSearch] = useState('');
const [selected, setSelected] = useState(null);
const [showComponents, setShowComponents] = useState(false);
const { data: rows } = useAsync(() => listSoftware(showComponents), [showComponents]);
const { data: installs } = useAsync(() => (selected ? listSoftwareDevices(selected) : Promise.resolve([])), [selected]);
if (!rows) return <div className="text-muted">{t('common_loading')}</div>;
const columns = [
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
{ key: 'version_count', label: t('eq_versions') },
{ key: 'device_count', label: t('eq_installed_on') },
];
return (
<>
<div className="toolbar">
<label className="checkbox-row">
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
{t('eq_show_components')}
</label>
<SearchInput value={search} onChange={setSearch} />
</div>
<ListView
columns={columns} rows={rows} searchValue={search} getRowKey={(r) => r.name}
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''}`} onRowClick={(r) => setSelected(r.name)}
initialSort={{ key: 'device_count', dir: -1 }}
/>
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
{ key: 'hostname', label: t('eq_hostname') }, { key: 'version', label: t('eq_version') }, { key: 'last_user', label: t('eq_last_user') },
]} />
</DetailPopup>
</>
);
}
function AgentsTab() {
const { t, pushToast, openDialog } = useUi();
const runAction = useAction();
const { data: tokens, reload } = useAsync(listInventoryTokens, []);
const [label, setLabel] = useState('');
const [created, setCreated] = useState(null); // { label, token } mostrado UNA vez
const serverUrl = `${window.location.origin}/api/inventory`;
function create() {
runAction(async () => {
const res = await createInventoryToken(label.trim());
setLabel('');
setCreated(res);
await reload();
});
}
function copy(text) {
navigator.clipboard?.writeText(text).then(() => pushToast({ tone: 'success', message: t('eq_copied') })).catch(() => {});
}
function confirmDelete(tk) {
openDialog({
tone: 'warning', title: t('common_confirm_delete_title'), message: t('eq_token_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await deleteInventoryToken(tk.id); await reload(); }) },
],
});
}
if (!tokens) return <div className="text-muted">{t('common_loading')}</div>;
return (
<>
<div className="kpi-card" style={{ marginBottom: 16 }}>
<div className="kpi-label" style={{ marginBottom: 6 }}>{t('eq_agent_setup')}</div>
<div style={{ fontSize: '.8rem', marginBottom: 8 }}>{t('eq_agent_setup_hint')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<code style={{ flex: 1, wordBreak: 'break-all' }}>server = {serverUrl}</code>
<button className="icon-btn" onClick={() => copy(serverUrl)}><Copy size={15} /></button>
</div>
<div style={{ fontSize: '.75rem', marginTop: 8 }} className="text-muted">{t('eq_agent_setup_auth')}</div>
</div>
<div className="toolbar">
<div className="toolbar-left">
<input className="input" style={{ width: 260 }} placeholder={t('eq_token_label')} value={label} onChange={(e) => setLabel(e.target.value)} />
<Button variant="accent" icon={Plus} onClick={create} disabled={!label.trim()}>{t('eq_token_new')}</Button>
</div>
</div>
{created && (
<div className="kpi-card" style={{ marginBottom: 16, borderColor: 'rgb(var(--accent))' }}>
<div className="kpi-label">{t('eq_token_created')} <strong>{created.label}</strong></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '8px 0' }}>
<code style={{ flex: 1, wordBreak: 'break-all' }}>{created.token}</code>
<button className="icon-btn" onClick={() => copy(created.token)}><Copy size={15} /></button>
</div>
<div style={{ fontSize: '.75rem' }} className="text-muted">{t('eq_token_once')}</div>
<div style={{ marginTop: 8 }}><Button variant="standard" onClick={() => setCreated(null)}>{t('common_close')}</Button></div>
</div>
)}
<ListView
columns={[
{ key: 'label', label: t('eq_token_label'), render: (r) => <strong>{r.label}</strong> },
{ key: 'is_active', label: t('common_status'), render: (r) => <Badge color={r.is_active ? 'success' : 'muted'}>{r.is_active ? t('common_active') : t('common_inactive')}</Badge> },
{ key: 'last_used_at', label: t('eq_token_last_used'), render: (r) => fmtDate(r.last_used_at) },
{ key: 'created_by_name', label: t('eq_token_created_by'), render: (r) => dash(r.created_by_name) },
{
key: 'actions', label: '', sortable: false,
render: (r) => (
<span style={{ display: 'flex', gap: 4 }} onClick={(e) => e.stopPropagation()}>
<button className="icon-btn" title={r.is_active ? t('eq_token_disable') : t('eq_token_enable')}
onClick={() => runAction(async () => { await setInventoryTokenActive(r.id, !r.is_active); await reload(); })}><Power size={15} /></button>
<button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(r)}><Trash2 size={15} /></button>
</span>
),
},
]}
rows={tokens} getRowKey={(r) => r.id}
/>
</>
);
}
export default function EquiposPage() {
const { t } = useUi();
const { user } = useAuth();
const canTokens = ['superadmin', 'admin'].includes(user?.roleCode);
const [tab, setTab] = useState('summary');
const tabs = [
{ key: 'summary', label: t('eq_tab_summary') },
{ key: 'devices', label: t('eq_tab_devices') },
{ key: 'software', label: t('eq_tab_software_all') },
...(canTokens ? [{ key: 'agents', label: t('eq_tab_agents') }] : []),
];
return (
<div>
<h2 className="page-title mt-0">{t('nav_devices')}</h2>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
<div style={{ marginTop: 14 }}>
{tab === 'summary' && <SummaryTab />}
{tab === 'devices' && <DevicesTab />}
{tab === 'software' && <SoftwareTab />}
{tab === 'agents' && canTokens && <AgentsTab />}
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { api } from './api.js';
export const listDevices = (params = {}) => {
const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
return api.get(`/devices${qs ? `?${qs}` : ''}`);
};
export const getDevice = (id) => api.get(`/devices/${id}`);
export const updateDevice = (id, data) => api.put(`/devices/${id}`, data);
export const getDevicesDashboard = () => api.get('/devices/dashboard');
export const listSoftware = (all) => api.get(`/devices/software${all ? '?all=1' : ''}`);
export const listSoftwareDevices = (name) => api.get(`/devices/software/devices?name=${encodeURIComponent(name)}`);
export const listInventoryTokens = () => api.get('/devices/tokens');
export const createInventoryToken = (label) => api.post('/devices/tokens', { label });
export const setInventoryTokenActive = (id, isActive) => api.patch(`/devices/tokens/${id}`, { isActive });
export const deleteInventoryToken = (id) => api.delete(`/devices/tokens/${id}`);