e65111a7f4
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
625 lines
33 KiB
React
625 lines
33 KiB
React
import { useEffect, useState } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { Navigate } from 'react-router-dom';
|
|
import { Pencil, Save, Plus, Trash2, Power, Copy, Download } 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,
|
|
getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer,
|
|
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
|
|
} 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 { DraftNotice } from '../../components/DraftNotice.jsx';
|
|
import { useDraftForm, useUserDrafts } from '../../hooks/useDraftForm.js';
|
|
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';
|
|
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
|
|
|
|
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, canEdit, tab = 'general', onTabChange }) {
|
|
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 setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
|
|
const [searches, setSearches] = useState({});
|
|
const [showComponents, setShowComponents] = useState(false);
|
|
const [editing, setEditing] = useState(false);
|
|
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
|
|
const draft = useDraftForm({
|
|
formId: 'device-edit', entityId: id,
|
|
initial: device ? { status: device.status, assignedUserId: device.assigned_user_id || '', departmentId: device.department_id || '', notes: device.notes || '', editVersion: device.edit_version } : {},
|
|
label: `${t('eq_detail')}: ${device?.hostname || id}`, enabled: !!device && canEdit && tab === 'general', onDiscard: () => setEditing(false),
|
|
});
|
|
const form = draft.values;
|
|
const setForm = draft.setValues;
|
|
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
|
|
|
|
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() {
|
|
setEditing(true);
|
|
}
|
|
|
|
function save() {
|
|
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
|
|
draft.guardSave(() => 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,
|
|
editVersion: form.editVersion,
|
|
});
|
|
draft.clear();
|
|
setEditing(false);
|
|
await reload();
|
|
onChanged();
|
|
pushToast({ tone: 'success', message: t('common_saved') });
|
|
}), { fetchCurrent: async () => { const d = await getDevice(id); return { status: d.status, assignedUserId: d.assigned_user_id || '', departmentId: d.department_id || '', notes: d.notes || '' }; } });
|
|
}
|
|
|
|
if (!device) return <div className="text-muted">{t('common_loading')}</div>;
|
|
|
|
const tabs = [
|
|
{ key: 'general', label: t('eq_tab_general'), dirty: editing && draft.isDirty },
|
|
{ key: 'users', label: t('eq_tab_users'), count: device.accounts.filter((a) => a.kind === 'local_user').length },
|
|
{ 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_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_first_seen'), fmtDate(device.first_seen), fmtDate(device.first_seen)],
|
|
[t('eq_last_seen'), fmtDate(device.last_seen), fmtDate(device.last_seen)],
|
|
[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 sessionNames = device.accounts.filter((a) => a.kind === 'session').map((a) => (a.domain ? `${a.domain}\\${a.name}` : a.name));
|
|
const allLocalUsers = device.accounts.filter((a) => a.kind === 'local_user');
|
|
const allLocalGroups = device.accounts.filter((a) => a.kind === 'local_group');
|
|
const userRows = [
|
|
[t('eq_users_session'), val(sessionNames.join(', ')), sessionNames.join(' ')],
|
|
[t('eq_users_local_count'), String(allLocalUsers.length), String(allLocalUsers.length)],
|
|
[t('eq_groups_local_count'), String(allLocalGroups.length), String(allLocalGroups.length)],
|
|
];
|
|
const localUsers = only(allLocalUsers, (r) => [r.name, r.sid]);
|
|
const localGroups = only(allLocalGroups, (r) => [r.name, r.sid]);
|
|
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' && (
|
|
<>
|
|
{!editing ? (
|
|
<>
|
|
{showRows(manualRows)}
|
|
{q && showRows(infoRows).length + showRows(manualRows).length === 0 && <div className="text-muted">{t('common_search_no_results')}</div>}
|
|
{canEdit && <div style={{ marginTop: 10 }}><Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button></div>}
|
|
</>
|
|
) : (
|
|
<>
|
|
<DraftNotice draft={draft} />
|
|
<Field label={t('common_status')} dirty={draft.isFieldDirty('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')} dirty={draft.isFieldDirty('assignedUserId')}>
|
|
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.assignedUserId} onChange={(v) => setForm({ ...form, assignedUserId: v })} />
|
|
</Field>
|
|
<Field label={t('common_department')} dirty={draft.isFieldDirty('departmentId')}>
|
|
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm({ ...form, departmentId: v })} />
|
|
</Field>
|
|
<Row label={t('eq_first_seen')}>{fmtDate(device.first_seen)}</Row>
|
|
<Row label={t('eq_last_seen')}>{fmtDate(device.last_seen)}</Row>
|
|
<Field label={t('eq_notes')} dirty={draft.isFieldDirty('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={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
<hr style={{ margin: '12px 0', border: 0, borderTop: '1px solid rgb(var(--line))' }} />
|
|
{showRows(infoRows)}
|
|
</>
|
|
)}
|
|
|
|
{tab === 'users' && (
|
|
<>
|
|
{showRows(userRows)}
|
|
{hwSection(t('eq_users_local'), localUsers, [
|
|
{ key: 'name', label: t('common_name') }, { key: 'sid', label: 'SID' },
|
|
])}
|
|
{hwSection(t('eq_groups_local'), localGroups, [
|
|
{ key: 'name', label: t('common_name') }, { key: 'sid', label: 'SID' },
|
|
])}
|
|
{q && showRows(userRows).length + localUsers.length + localGroups.length === 0 && <div className="text-muted">{t('common_search_no_results')}</div>}
|
|
<div className="text-muted" style={{ fontSize: '.72rem', marginTop: 12 }}>{t('eq_users_hint')}</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({ canEdit }) {
|
|
const { t } = useUi();
|
|
const [search, setSearch] = useState('');
|
|
const [kind, setKind] = useState('');
|
|
const [status, setStatus] = useState('active');
|
|
// Equipo abierto y su pestaña en la URL (?device=&deviceTab=): F5, retorno tras el login y
|
|
// aviso de cambios sin guardar al cerrar el popup.
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const deviceId = searchParams.get('device') ? Number(searchParams.get('device')) : null;
|
|
const deviceTab = searchParams.get('deviceTab') || 'general';
|
|
const setDeviceParams = (id, tab) => setSearchParams((prev) => {
|
|
const next = new URLSearchParams(prev);
|
|
if (id) { next.set('device', id); if (tab && tab !== 'general') next.set('deviceTab', tab); else next.delete('deviceTab'); }
|
|
else { next.delete('device'); next.delete('deviceTab'); }
|
|
return next;
|
|
}, { replace: true });
|
|
const selected = deviceId ? { id: deviceId, view: 'info' } : null;
|
|
const setSelected = (v) => setDeviceParams(v?.id || null);
|
|
const { data: devices, reload } = useAsync(() => listDevices({ kind, status }), [kind, status]);
|
|
// Al volver con un borrador de un equipo (que el usuario sigue viendo), se reabre su popup.
|
|
const drafts = useUserDrafts();
|
|
useEffect(() => {
|
|
if (!devices || deviceId) return;
|
|
const d = drafts.find((x) => x.formId === 'device-edit' && devices.some((dv) => String(dv.id) === String(x.entityId)));
|
|
if (d) setDeviceParams(Number(d.entityId));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [devices]);
|
|
|
|
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) },
|
|
];
|
|
|
|
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} canEdit={canEdit}
|
|
tab={deviceTab} onTabChange={(k) => setDeviceParams(selected.id, k)}
|
|
/>
|
|
)}
|
|
</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 ShareTab() {
|
|
const { t, openDialog } = useUi();
|
|
const runAction = useAction();
|
|
const { data: viewers, reload } = useAsync(listDeviceViewers, []);
|
|
const { data: users } = useAsync(listUsers, []);
|
|
const [userId, setUserId] = useState('');
|
|
|
|
if (!viewers || !users) return <div className="text-muted">{t('common_loading')}</div>;
|
|
const taken = new Set(viewers.map((v) => v.user_id));
|
|
// Los roles de TI ya ven Equipos completo; solo se comparte con el resto de usuarios activos.
|
|
const options = users
|
|
.filter((u) => u.is_active && !taken.has(u.id) && !['superadmin', 'admin', 'it_manager'].includes(u.role_code))
|
|
.map((u) => ({ value: u.id, label: `${u.name} · ${u.email}` }));
|
|
|
|
function add() {
|
|
runAction(async () => { await addDeviceViewer(Number(userId)); setUserId(''); await reload(); });
|
|
}
|
|
|
|
function confirmRemove(v) {
|
|
openDialog({
|
|
tone: 'warning', title: t('eq_share_remove_title'), message: `${v.name}: ${t('eq_share_remove_msg')}`,
|
|
actions: [
|
|
{ label: t('common_cancel'), variant: 'standard' },
|
|
{ label: t('eq_share_remove'), variant: 'danger', onClick: () => runAction(async () => { await removeDeviceViewer(v.user_id); await reload(); }) },
|
|
],
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="kpi-card" style={{ marginBottom: 16 }}>
|
|
<div className="kpi-label" style={{ marginBottom: 6 }}>{t('eq_share_title')}</div>
|
|
<div style={{ fontSize: '.8rem', marginBottom: 10 }}>{t('eq_share_hint')}</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1, maxWidth: 460 }}><ComboBox options={options} value={userId} onChange={setUserId} placeholder={t('eq_share_pick')} /></div>
|
|
<Button variant="accent" icon={Plus} onClick={add} disabled={!userId}>{t('eq_share_add')}</Button>
|
|
</div>
|
|
</div>
|
|
{viewers.length === 0 ? <div className="text-muted">{t('eq_share_empty')}</div> : (
|
|
<ListView
|
|
columns={[
|
|
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
|
|
{ key: 'email', label: 'Email' },
|
|
{ key: 'role_name', label: t('eq_share_role') },
|
|
{ key: 'created_at', label: t('eq_share_since'), render: (r) => fmtDate(r.created_at) },
|
|
{ key: 'actions', label: '', sortable: false, render: (r) => <button className="icon-btn" title={t('eq_share_remove')} onClick={() => confirmRemove(r)}><Trash2 size={15} /></button> },
|
|
]}
|
|
rows={viewers} getRowKey={(r) => r.user_id}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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`;
|
|
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() {
|
|
runAction(async () => {
|
|
const res = await createInventoryToken(label.trim());
|
|
setLabel('');
|
|
setCreated(res);
|
|
setInstallToken(res.token);
|
|
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="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-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.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')}
|
|
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 { data: access } = useAsync(getDevicesAccess, []);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const tab = searchParams.get('tab') || 'summary';
|
|
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
|
const drafts = useUserDrafts();
|
|
const devicesDirty = drafts.some((d) => d.formId === 'device-edit');
|
|
if (!access) return <div className="text-muted">{t('common_loading')}</div>;
|
|
if (!access.canView) return <Navigate to="/" replace />;
|
|
const tabs = [
|
|
{ key: 'summary', label: t('eq_tab_summary') },
|
|
{ key: 'devices', label: t('eq_tab_devices'), dirty: devicesDirty },
|
|
{ key: 'software', label: t('eq_tab_software_all') },
|
|
...(access.canManage ? [{ key: 'agents', label: t('eq_tab_agents') }, { key: 'share', label: t('eq_tab_share') }] : []),
|
|
];
|
|
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 canEdit={access.canEdit} />}
|
|
{tab === 'software' && <SoftwareTab />}
|
|
{tab === 'agents' && access.canManage && <AgentsTab />}
|
|
{tab === 'share' && access.canManage && <ShareTab />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|