From 4235f640d9bb4295d6ec1db0c261b8d86fca89e4 Mon Sep 17 00:00:00 2001 From: Esteban Date: Mon, 16 Feb 2026 14:41:01 -0600 Subject: [PATCH] Almost all sections with mock data --- src/app/(dashboard)/archivos/page.tsx | 134 +++++++++++ src/app/(dashboard)/headwind/page.tsx | 73 ++++++ src/app/(dashboard)/layout.tsx | 7 + src/app/(dashboard)/librenms/page.tsx | 105 +++++++++ src/app/(dashboard)/rendimiento/page.tsx | 221 ++++++++++++++++++ src/app/(dashboard)/reportes/page.tsx | 185 +++++++++++++++ src/app/(dashboard)/sesiones/page.tsx | 141 +++++++++++ src/app/(dashboard)/software/page.tsx | 207 ++++++++++++++++ src/app/(dashboard)/terminal/page.tsx | 114 +++++++++ .../files/FileExplorerContainer.tsx | 91 ++++++++ src/components/files/FileRow.tsx | 50 ++++ src/components/files/FileTable.tsx | 35 +++ src/components/headwind/CorporateAppRow.tsx | 28 +++ src/components/headwind/CorporateAppsList.tsx | 33 +++ src/components/headwind/DeviceItem.tsx | 38 +++ .../headwind/HeadwindMetricCard.tsx | 45 ++++ src/components/headwind/MobileDeviceList.tsx | 33 +++ src/components/layout/Sidebar.tsx | 11 +- src/components/librenms/AlertItem.tsx | 33 +++ src/components/librenms/AlertList.tsx | 31 +++ src/components/librenms/DeviceList.tsx | 33 +++ src/components/librenms/DeviceRow.tsx | 37 +++ .../librenms/LibrenmsMetricCard.tsx | 45 ++++ src/components/librenms/LibrenmsRow.tsx | 49 ++++ src/components/librenms/StatusBadge.tsx | 26 +++ src/components/performance/LineChart.tsx | 57 +++++ src/components/performance/MetricCard.tsx | 72 ++++++ src/components/performance/ProcessRow.tsx | 36 +++ src/components/performance/ProcessTable.tsx | 65 ++++++ src/components/reportes/DateRangeFilter.tsx | 60 +++++ src/components/reportes/ReportCard.tsx | 62 +++++ src/components/sessions/SessionCard.tsx | 95 ++++++++ src/components/software/SoftwareRow.tsx | 48 ++++ src/components/software/SoftwareTable.tsx | 42 ++++ src/components/software/SummaryMetricCard.tsx | 31 +++ src/components/terminal/QuickCommands.tsx | 36 +++ src/components/terminal/TerminalWindow.tsx | 153 ++++++++++++ src/lib/utils.ts | 8 + src/mocks/mdmDashboardData.ts | 58 +++++ src/mocks/reportService.ts | 100 ++++++++ src/server/trpc/routers/clientes.router.ts | 9 +- src/server/trpc/routers/index.ts | 2 + src/server/trpc/routers/sesiones.router.ts | 47 ++++ 43 files changed, 2782 insertions(+), 4 deletions(-) create mode 100644 src/app/(dashboard)/archivos/page.tsx create mode 100644 src/app/(dashboard)/headwind/page.tsx create mode 100644 src/app/(dashboard)/librenms/page.tsx create mode 100644 src/app/(dashboard)/rendimiento/page.tsx create mode 100644 src/app/(dashboard)/reportes/page.tsx create mode 100644 src/app/(dashboard)/sesiones/page.tsx create mode 100644 src/app/(dashboard)/software/page.tsx create mode 100644 src/app/(dashboard)/terminal/page.tsx create mode 100644 src/components/files/FileExplorerContainer.tsx create mode 100644 src/components/files/FileRow.tsx create mode 100644 src/components/files/FileTable.tsx create mode 100644 src/components/headwind/CorporateAppRow.tsx create mode 100644 src/components/headwind/CorporateAppsList.tsx create mode 100644 src/components/headwind/DeviceItem.tsx create mode 100644 src/components/headwind/HeadwindMetricCard.tsx create mode 100644 src/components/headwind/MobileDeviceList.tsx create mode 100644 src/components/librenms/AlertItem.tsx create mode 100644 src/components/librenms/AlertList.tsx create mode 100644 src/components/librenms/DeviceList.tsx create mode 100644 src/components/librenms/DeviceRow.tsx create mode 100644 src/components/librenms/LibrenmsMetricCard.tsx create mode 100644 src/components/librenms/LibrenmsRow.tsx create mode 100644 src/components/librenms/StatusBadge.tsx create mode 100644 src/components/performance/LineChart.tsx create mode 100644 src/components/performance/MetricCard.tsx create mode 100644 src/components/performance/ProcessRow.tsx create mode 100644 src/components/performance/ProcessTable.tsx create mode 100644 src/components/reportes/DateRangeFilter.tsx create mode 100644 src/components/reportes/ReportCard.tsx create mode 100644 src/components/sessions/SessionCard.tsx create mode 100644 src/components/software/SoftwareRow.tsx create mode 100644 src/components/software/SoftwareTable.tsx create mode 100644 src/components/software/SummaryMetricCard.tsx create mode 100644 src/components/terminal/QuickCommands.tsx create mode 100644 src/components/terminal/TerminalWindow.tsx create mode 100644 src/mocks/mdmDashboardData.ts create mode 100644 src/mocks/reportService.ts create mode 100644 src/server/trpc/routers/sesiones.router.ts diff --git a/src/app/(dashboard)/archivos/page.tsx b/src/app/(dashboard)/archivos/page.tsx new file mode 100644 index 0000000..07ea440 --- /dev/null +++ b/src/app/(dashboard)/archivos/page.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useState, useMemo, useCallback } from 'react' +import { useSelectedClient } from '@/components/providers/SelectedClientProvider' +import { trpc } from '@/lib/trpc-client' +import FileExplorerContainer from '@/components/files/FileExplorerContainer' +import type { FileItem } from '@/components/files/FileRow' + +const MOCK_FILES_BY_PATH: Record = { + 'C:\\': [ + { id: '1', name: 'Documents', type: 'folder', size: null, modifiedAt: '2026-02-15' }, + { id: '2', name: 'Windows', type: 'folder', size: null, modifiedAt: '2026-02-10' }, + { id: '3', name: 'Users', type: 'folder', size: null, modifiedAt: '2026-02-14' }, + { id: '4', name: 'archivo.txt', type: 'file', size: '2.4 KB', modifiedAt: '2026-02-12' }, + { id: '5', name: 'config.ini', type: 'file', size: '1.1 KB', modifiedAt: '2026-02-11' }, + ], + 'C:\\Documents': [ + { id: 'd1', name: '..', type: 'folder', size: null, modifiedAt: '—' }, + { id: 'd2', name: 'Subcarpeta', type: 'folder', size: null, modifiedAt: '2026-02-14' }, + { id: 'd3', name: 'readme.txt', type: 'file', size: '512 B', modifiedAt: '2026-02-13' }, + { id: 'd4', name: 'reporte.pdf', type: 'file', size: '245 KB', modifiedAt: '2026-02-15' }, + ], + 'C:\\Windows': [ + { id: 'w1', name: '..', type: 'folder', size: null, modifiedAt: '—' }, + { id: 'w2', name: 'System32', type: 'folder', size: null, modifiedAt: '2026-02-10' }, + { id: 'w3', name: 'Temp', type: 'folder', size: null, modifiedAt: '2026-02-15' }, + ], + 'C:\\Users': [ + { id: 'u1', name: '..', type: 'folder', size: null, modifiedAt: '—' }, + { id: 'u2', name: 'Public', type: 'folder', size: null, modifiedAt: '2026-02-14' }, + { id: 'u3', name: 'Admin', type: 'folder', size: null, modifiedAt: '2026-02-15' }, + ], + 'C:\\Documents\\Subcarpeta': [ + { id: 's1', name: '..', type: 'folder', size: null, modifiedAt: '—' }, + { id: 's2', name: 'datos.xlsx', type: 'file', size: '18 KB', modifiedAt: '2026-02-15' }, + ], +} + +function getFilesForPath(path: string): FileItem[] { + const normalized = path.replace(/\/$/, '').replace(/\//g, '\\') + return MOCK_FILES_BY_PATH[normalized] ?? [{ + id: 'back', + name: '..', + type: 'folder', + size: null, + modifiedAt: '—', + }] +} + +export default function FileExplorerPage() { + const { selectedClientId } = useSelectedClient() + const clienteId = selectedClientId ?? undefined + + const listQuery = trpc.equipos.list.useQuery( + { clienteId, limit: 100 }, + { refetchOnWindowFocus: false } + ) + + const devices = useMemo( + () => (listQuery.data?.dispositivos ?? []).map((d) => ({ id: d.id, nombre: d.nombre })), + [listQuery.data] + ) + + const [selectedDeviceId, setSelectedDeviceId] = useState('') + const [currentPath, setCurrentPath] = useState('C:\\') + + const selectedDevice = selectedDeviceId + ? devices.find((d) => d.id === selectedDeviceId) + : null + const selectedDeviceName = selectedDevice?.nombre ?? null + + const files = useMemo(() => getFilesForPath(currentPath), [currentPath]) + + const handleFolderClick = useCallback((name: string) => { + setCurrentPath((prev) => { + if (name === '..') { + const parts = prev.replace(/\\$/, '').split('\\') + parts.pop() + return parts.length > 1 ? parts.join('\\') + '\\' : 'C:\\' + } + const sep = prev.endsWith('\\') ? '' : '\\' + return `${prev}${sep}${name}` + }) + }, []) + + const handleRefresh = useCallback(() => { + // Mock: no-op; in real impl would refetch file list + }, []) + + const handleUpload = useCallback(() => { + // Mock: no-op; in real impl would open file picker / upload + }, []) + + return ( +
+
+
+

+ Explorador de Archivos +

+

+ Navega y transfiere archivos remotamente +

+
+
+ +
+
+ + +
+ ) +} diff --git a/src/app/(dashboard)/headwind/page.tsx b/src/app/(dashboard)/headwind/page.tsx new file mode 100644 index 0000000..f525322 --- /dev/null +++ b/src/app/(dashboard)/headwind/page.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useState, useMemo } from 'react' +import { useSelectedClient } from '@/components/providers/SelectedClientProvider' +import HeadwindMetricCard from '@/components/headwind/HeadwindMetricCard' +import MobileDeviceList from '@/components/headwind/MobileDeviceList' +import CorporateAppsList from '@/components/headwind/CorporateAppsList' +import { + getMdmDashboardData, + type Device, + type AppDeployment, + type DashboardStats, +} from '@/mocks/mdmDashboardData' + +export default function HeadwindPage() { + useSelectedClient() + + const initial = useMemo(() => getMdmDashboardData(), []) + const [stats] = useState(initial.stats) + const [devices] = useState(initial.devices) + const [appDeployments] = useState(initial.appDeployments) + + return ( +
+
+

+ Headwind MDM +

+

+ Gestión de dispositivos móviles Android +

+
+ +
+ + + + +
+ +
+ + +
+
+ ) +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index ae7ee41..02f25c3 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -94,6 +94,12 @@ function DashboardContentInner({ ) const devicesCount = devicesCountQuery.data?.pagination?.total ?? 0 + const sessionsCountQuery = trpc.sesiones.count.useQuery( + { clienteId }, + { refetchOnWindowFocus: true, staleTime: 15 * 1000 } + ) + const sessionsCount = sessionsCountQuery.data ?? 0 + const clientsQuery = trpc.clientes.list.useQuery( { limit: 100 }, { staleTime: 60 * 1000 } @@ -111,6 +117,7 @@ function DashboardContentInner({ setSidebarOpen(false)} /> diff --git a/src/app/(dashboard)/librenms/page.tsx b/src/app/(dashboard)/librenms/page.tsx new file mode 100644 index 0000000..190d6f7 --- /dev/null +++ b/src/app/(dashboard)/librenms/page.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useState } from 'react' +import { useSelectedClient } from '@/components/providers/SelectedClientProvider' +import LibrenmsMetricCard from '@/components/librenms/LibrenmsMetricCard' +import DeviceList from '@/components/librenms/DeviceList' +import AlertList from '@/components/librenms/AlertList' +import type { NetworkDevice } from '@/components/librenms/DeviceRow' +import type { SnmpAlert } from '@/components/librenms/AlertItem' + +const MOCK_DEVICES: NetworkDevice[] = [ + { id: '1', name: 'Core Switch', model: 'Cisco 3850', status: 'online' }, + { id: '2', name: 'Router Principal', model: 'MikroTik CCR', status: 'online' }, + { id: '3', name: 'AP-Oficina-01', model: 'Ubiquiti UAP', status: 'online' }, + { id: '4', name: 'Switch-Piso2', model: 'HP ProCurve', status: 'warning' }, + { id: '5', name: 'Firewall', model: 'pfSense', status: 'online' }, +] + +const MOCK_ALERTS: SnmpAlert[] = [ + { + id: '1', + title: 'CPU Alto – Core Switch', + detail: 'Hace 15 min – 92% utilización', + severity: 'critical', + }, + { + id: '2', + title: 'Puerto Down – Switch-Piso2', + detail: 'Hace 1h – GigabitEthernet0/12', + severity: 'warning', + }, + { + id: '3', + title: 'Alto tráfico – WAN', + detail: 'Hace 2h – 95% capacidad', + severity: 'critical', + }, +] + +export default function LibrenmsPage() { + useSelectedClient() + + const [devices] = useState(MOCK_DEVICES) + const [alerts] = useState(MOCK_ALERTS) + const [metrics] = useState({ + totalDevices: 28, + uptime: 99.7, + activeAlerts: 3, + trafficToday: 847, + }) + + return ( +
+ {/* Page header */} +
+

+ LibreNMS - Monitoreo de Red +

+

+ SNMP, NetFlow y alertas de infraestructura +

+
+ + {/* KPI metric cards */} +
+ + + + +
+ + {/* Main content grid */} +
+ + +
+
+ ) +} diff --git a/src/app/(dashboard)/rendimiento/page.tsx b/src/app/(dashboard)/rendimiento/page.tsx new file mode 100644 index 0000000..4c561be --- /dev/null +++ b/src/app/(dashboard)/rendimiento/page.tsx @@ -0,0 +1,221 @@ +'use client' + +import { useState, useMemo, useEffect, useCallback } from 'react' +import { useSelectedClient } from '@/components/providers/SelectedClientProvider' +import { trpc } from '@/lib/trpc-client' +import MetricCard from '@/components/performance/MetricCard' +import ProcessTable from '@/components/performance/ProcessTable' +import type { ProcessItem } from '@/components/performance/ProcessRow' + +const CHART_POINTS = 20 +const POLL_INTERVAL_MS = 2000 + +function randomIn(min: number, max: number) { + return Math.round(min + Math.random() * (max - min)) +} + +function generateMockProcesses(): ProcessItem[] { + const names = [ + 'chrome.exe', + 'Code.exe', + 'node.exe', + 'System', + 'svchost.exe', + 'explorer.exe', + 'MsMpEng.exe', + 'SearchHost.exe', + 'RuntimeBroker.exe', + 'dllhost.exe', + ] + return names.slice(0, 10).map((name, i) => ({ + id: `p-${i}`, + name, + pid: 1000 + i * 100 + randomIn(0, 99), + cpu: randomIn(0, 45), + memory: `${randomIn(50, 800)} MB`, + state: i % 3 === 0 ? 'En ejecución' : 'Activo', + })) +} + +export default function RendimientoPage() { + const { selectedClientId } = useSelectedClient() + const clienteId = selectedClientId ?? undefined + + const listQuery = trpc.equipos.list.useQuery( + { clienteId, limit: 100 }, + { refetchOnWindowFocus: false } + ) + + const devices = useMemo( + () => (listQuery.data?.dispositivos ?? []).map((d) => ({ id: d.id, nombre: d.nombre })), + [listQuery.data] + ) + + const [selectedDeviceId, setSelectedDeviceId] = useState('') + const [metrics, setMetrics] = useState({ + cpu: 0, + memory: 0, + disk: 0, + network: 0, + }) + const [chartHistory, setChartHistory] = useState<{ + cpu: number[] + memory: number[] + disk: number[] + network: number[] + }>({ + cpu: [], + memory: [], + disk: [], + network: [], + }) + const [processes, setProcesses] = useState([]) + const [loading, setLoading] = useState(false) + + const hasDevice = !!selectedDeviceId + + const tick = useCallback(() => { + const cpu = randomIn(15, 85) + const memory = randomIn(40, 90) + const disk = randomIn(25, 65) + const network = randomIn(5, 120) + setMetrics({ cpu, memory, disk, network }) + setChartHistory((prev) => ({ + cpu: [...prev.cpu, cpu].slice(-CHART_POINTS), + memory: [...prev.memory, memory].slice(-CHART_POINTS), + disk: [...prev.disk, disk].slice(-CHART_POINTS), + network: [...prev.network, network].slice(-CHART_POINTS), + })) + setProcesses(generateMockProcesses()) + }, []) + + useEffect(() => { + if (!hasDevice) { + setMetrics({ cpu: 0, memory: 0, disk: 0, network: 0 }) + setChartHistory({ cpu: [], memory: [], disk: [], network: [] }) + setProcesses([]) + return + } + setLoading(true) + tick() + const t = setTimeout(() => setLoading(false), 400) + const interval = setInterval(tick, POLL_INTERVAL_MS) + return () => { + clearTimeout(t) + clearInterval(interval) + } + }, [hasDevice, tick]) + + const cpuFooter = hasDevice + ? [ + { label: 'Procesos:', value: '142' }, + { label: 'Hilos:', value: '2,840' }, + { label: 'Velocidad:', value: '2.90 GHz' }, + ] + : [ + { label: 'Procesos:', value: '—' }, + { label: 'Hilos:', value: '—' }, + { label: 'Velocidad:', value: '—' }, + ] + + const ramFooter = hasDevice + ? [ + { label: 'En uso:', value: `${Math.round((metrics.memory / 100) * 16)} GB` }, + { label: 'Disponible:', value: `${Math.round(((100 - metrics.memory) / 100) * 16)} GB` }, + { label: 'Total:', value: '16 GB' }, + ] + : [ + { label: 'En uso:', value: '—' }, + { label: 'Disponible:', value: '—' }, + { label: 'Total:', value: '—' }, + ] + + const diskFooter = hasDevice + ? [ + { label: 'Lectura:', value: '12.5 MB/s' }, + { label: 'Escritura:', value: '3.2 MB/s' }, + { label: 'Activo:', value: 'Sí' }, + ] + : [ + { label: 'Lectura:', value: '—' }, + { label: 'Escritura:', value: '—' }, + { label: 'Activo:', value: '—' }, + ] + + const networkFooter = hasDevice + ? [ + { label: 'Enviado:', value: '1.2 GB' }, + { label: 'Recibido:', value: '4.8 GB' }, + { label: 'Adaptador:', value: 'Ethernet' }, + ] + : [ + { label: 'Enviado:', value: '—' }, + { label: 'Recibido:', value: '—' }, + { label: 'Adaptador:', value: '—' }, + ] + + return ( +
+
+
+

+ Rendimiento en Tiempo Real +

+

+ Monitorea recursos del sistema +

+
+
+ +
+
+ +
+ 80} + /> + 80} + /> + 80} + /> + +
+ + +
+ ) +} diff --git a/src/app/(dashboard)/reportes/page.tsx b/src/app/(dashboard)/reportes/page.tsx new file mode 100644 index 0000000..c6d6790 --- /dev/null +++ b/src/app/(dashboard)/reportes/page.tsx @@ -0,0 +1,185 @@ +'use client' + +// TODO: replace mock reportService with trpc.reportes.inventario / reportes.alertas / reportes.exportarCSV and real PDF generation +import { useState, useCallback } from 'react' +import { useSelectedClient } from '@/components/providers/SelectedClientProvider' +import DateRangeFilter, { type DateRangeValue } from '@/components/reportes/DateRangeFilter' +import ReportCard from '@/components/reportes/ReportCard' +import { + fetchInventoryReport, + fetchResourceUsageReport, + fetchAlertsReport, +} from '@/mocks/reportService' + +function defaultDateRange(): DateRangeValue { + const today = new Date() + const monthAgo = new Date(today) + monthAgo.setMonth(monthAgo.getMonth() - 1) + return { + desde: monthAgo.toISOString().split('T')[0], + hasta: today.toISOString().split('T')[0], + } +} + +export default function ReportesPage() { + useSelectedClient() + + const [filters, setFilters] = useState(defaultDateRange) + const [loading, setLoading] = useState(null) + + const getDates = useCallback((): { start: Date; end: Date } | null => { + if (!filters.desde || !filters.hasta || filters.hasta < filters.desde) + return null + return { + start: new Date(filters.desde), + end: new Date(filters.hasta), + } + }, [filters]) + + const mockExportPdf = useCallback( + (reportName: string) => { + const dates = getDates() + console.log(`[Mock] Export PDF: ${reportName}`, dates || filters) + }, + [getDates, filters] + ) + + const mockExportExcel = useCallback( + (reportName: string) => { + const dates = getDates() + const csv = `Reporte,${reportName}\nDesde,${filters.desde || '-'}\nHasta,${filters.hasta || '-'}\n` + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `reporte-${reportName.toLowerCase().replace(/\s+/g, '-')}-${filters.desde || 'export'}.csv` + a.click() + URL.revokeObjectURL(url) + }, + [getDates, filters] + ) + + const handleInventoryPdf = () => { + setLoading('inventario-pdf') + const dates = getDates() + if (dates) { + fetchInventoryReport(dates.start, dates.end).then(() => { + mockExportPdf('Inventario de Equipos') + setLoading(null) + }) + } else { + mockExportPdf('Inventario de Equipos') + setLoading(null) + } + } + + const handleInventoryExcel = () => { + setLoading('inventario-excel') + const dates = getDates() + if (dates) { + fetchInventoryReport(dates.start, dates.end).then(() => { + mockExportExcel('Inventario de Equipos') + setLoading(null) + }) + } else { + mockExportExcel('Inventario de Equipos') + setLoading(null) + } + } + + const handleResourcePdf = () => { + setLoading('recursos-pdf') + const dates = getDates() + if (dates) { + fetchResourceUsageReport(dates.start, dates.end).then(() => { + mockExportPdf('Uso de Recursos') + setLoading(null) + }) + } else { + mockExportPdf('Uso de Recursos') + setLoading(null) + } + } + + const handleResourceExcel = () => { + setLoading('recursos-excel') + const dates = getDates() + if (dates) { + fetchResourceUsageReport(dates.start, dates.end).then(() => { + mockExportExcel('Uso de Recursos') + setLoading(null) + }) + } else { + mockExportExcel('Uso de Recursos') + setLoading(null) + } + } + + const handleAlertsPdf = () => { + setLoading('alertas-pdf') + const dates = getDates() + if (dates) { + fetchAlertsReport(dates.start, dates.end).then(() => { + mockExportPdf('Historial de Alertas') + setLoading(null) + }) + } else { + mockExportPdf('Historial de Alertas') + setLoading(null) + } + } + + const handleAlertsExcel = () => { + setLoading('alertas-excel') + const dates = getDates() + if (dates) { + fetchAlertsReport(dates.start, dates.end).then(() => { + mockExportExcel('Historial de Alertas') + setLoading(null) + }) + } else { + mockExportExcel('Historial de Alertas') + setLoading(null) + } + } + + return ( +
+
+

Reportes

+

Generación de informes del sistema

+
+ +
+ +
+ +
+ + + +
+
+ ) +} diff --git a/src/app/(dashboard)/sesiones/page.tsx b/src/app/(dashboard)/sesiones/page.tsx new file mode 100644 index 0000000..c993cb1 --- /dev/null +++ b/src/app/(dashboard)/sesiones/page.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useState, useMemo, useEffect } from 'react' +import { useSelectedClient } from '@/components/providers/SelectedClientProvider' +import { trpc } from '@/lib/trpc-client' +import { formatDurationSeconds } from '@/lib/utils' +import SessionCard, { type SessionTypeLabel } from '@/components/sessions/SessionCard' + +const TIPO_TO_LABEL: Record = { + desktop: 'Escritorio Remoto', + terminal: 'Terminal', + files: 'Archivos', +} + +function getSessionTypeLabel(tipo: string): SessionTypeLabel { + return TIPO_TO_LABEL[tipo] ?? 'Terminal' +} + +function computeDurationSeconds(startedAt: Date | string): number { + const start = new Date(startedAt).getTime() + return Math.floor((Date.now() - start) / 1000) +} + +export default function SesionesPage() { + const { selectedClientId } = useSelectedClient() + const clienteId = selectedClientId ?? undefined + const utils = trpc.useUtils() + + const listQuery = trpc.sesiones.list.useQuery( + { clienteId, limit: 100 }, + { refetchOnWindowFocus: true, staleTime: 10 * 1000 } + ) + + const [liveDurations, setLiveDurations] = useState>({}) + + const sessions = useMemo(() => { + const list = listQuery.data?.sessions ?? [] + return list.map((s) => { + const startedAt = s.iniciadaEn instanceof Date ? s.iniciadaEn : new Date(s.iniciadaEn) + const seconds = computeDurationSeconds(startedAt) + return { + id: s.id, + deviceName: s.dispositivo.nombre, + userEmail: s.usuario.email, + sessionType: getSessionTypeLabel(s.tipo), + startedAt, + initialSeconds: seconds, + } + }) + }, [listQuery.data]) + + useEffect(() => { + if (sessions.length === 0) return + const interval = setInterval(() => { + const next: Record = {} + sessions.forEach((s) => { + next[s.id] = computeDurationSeconds(s.startedAt) + }) + setLiveDurations((prev) => ({ ...prev, ...next })) + }, 1000) + return () => clearInterval(interval) + }, [sessions]) + + const endSessionMutation = trpc.equipos.finalizarSesion.useMutation({ + onSuccess: () => { + utils.sesiones.list.invalidate() + utils.sesiones.count.invalidate() + utils.clientes.dashboardStats.invalidate() + }, + }) + + const handleEndSession = (sessionId: string) => { + if (typeof window !== 'undefined' && !window.confirm('¿Terminar esta sesión?')) return + endSessionMutation.mutate({ sesionId: sessionId }) + } + + if (listQuery.isLoading) { + return ( +
+
+

Sesiones Activas

+

Conexiones remotas en curso

+
+
+ Cargando sesiones... +
+
+ ) + } + + if (listQuery.isError) { + return ( +
+
+

Sesiones Activas

+

Conexiones remotas en curso

+
+
+ Error al cargar sesiones. Intente de nuevo. +
+
+ ) + } + + return ( +
+
+

Sesiones Activas

+

Conexiones remotas en curso

+
+ + {sessions.length === 0 ? ( +
+ No hay sesiones activas. +
+ ) : ( +
    + {sessions.map((session) => { + const durationSeconds = liveDurations[session.id] ?? session.initialSeconds + const duration = formatDurationSeconds(durationSeconds) + const isEnding = endSessionMutation.isPending && endSessionMutation.variables?.sesionId === session.id + + return ( +
  • + +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/app/(dashboard)/software/page.tsx b/src/app/(dashboard)/software/page.tsx new file mode 100644 index 0000000..0e64ffc --- /dev/null +++ b/src/app/(dashboard)/software/page.tsx @@ -0,0 +1,207 @@ +'use client' + +import { useState, useMemo, useCallback } from 'react' +import { useSelectedClient } from '@/components/providers/SelectedClientProvider' +import SummaryMetricCard from '@/components/software/SummaryMetricCard' +import SoftwareTable from '@/components/software/SoftwareTable' +import type { SoftwareItem } from '@/components/software/SoftwareRow' +import { Download } from 'lucide-react' + +const MOCK_SOFTWARE: SoftwareItem[] = [ + { + id: '1', + name: 'Google Chrome', + version: '120.0.6099.130', + vendor: 'Google LLC', + installations: 10, + lastUpdate: '15/01/2024', + licensed: false, + needsUpdate: true, + }, + { + id: '2', + name: 'Microsoft Office 365', + version: '16.0.17029.20028', + vendor: 'Microsoft Corporation', + installations: 8, + lastUpdate: '20/01/2024', + licensed: true, + needsUpdate: false, + }, + { + id: '3', + name: 'Adobe Acrobat Reader DC', + version: '23.006.20360', + vendor: 'Adobe Inc.', + installations: 12, + lastUpdate: '10/01/2024', + licensed: true, + needsUpdate: true, + }, + { + id: '4', + name: 'Visual Studio Code', + version: '1.85.1', + vendor: 'Microsoft Corporation', + installations: 5, + lastUpdate: '18/01/2024', + licensed: false, + needsUpdate: false, + }, + { + id: '5', + name: 'Slack', + version: '4.33.90', + vendor: 'Slack Technologies', + installations: 7, + lastUpdate: '12/01/2024', + licensed: true, + needsUpdate: false, + }, + { + id: '6', + name: 'Zoom', + version: '5.16.10', + vendor: 'Zoom Video Communications', + installations: 6, + lastUpdate: '08/01/2024', + licensed: false, + needsUpdate: true, + }, + { + id: '7', + name: '7-Zip', + version: '23.01', + vendor: 'Igor Pavlov', + installations: 15, + lastUpdate: '22/12/2023', + licensed: false, + needsUpdate: false, + }, + { + id: '8', + name: 'Node.js', + version: '20.10.0', + vendor: 'OpenJS Foundation', + installations: 4, + lastUpdate: '05/01/2024', + licensed: false, + needsUpdate: true, + }, + { + id: '9', + name: 'Git', + version: '2.43.0', + vendor: 'Software Freedom Conservancy', + installations: 5, + lastUpdate: '14/01/2024', + licensed: false, + needsUpdate: false, + }, + { + id: '10', + name: 'Windows Security', + version: '10.0.22621.1', + vendor: 'Microsoft Corporation', + installations: 12, + lastUpdate: '19/01/2024', + licensed: true, + needsUpdate: false, + }, +] + +function exportToCsv(items: SoftwareItem[]): string { + const headers = ['Nombre', 'Versión', 'Editor', 'Instalaciones', 'Última actualización'] + const rows = items.map((i) => + [i.name, i.version, i.vendor, i.installations, i.lastUpdate].join(',') + ) + return [headers.join(','), ...rows].join('\n') +} + +export default function InventarioPage() { + useSelectedClient() + const [softwareList] = useState(MOCK_SOFTWARE) + const [search, setSearch] = useState('') + + const filteredSoftware = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return softwareList + return softwareList.filter((item) => + item.name.toLowerCase().includes(q) || + item.vendor.toLowerCase().includes(q) || + item.version.toLowerCase().includes(q) + ) + }, [softwareList, search]) + + const uniqueCount = filteredSoftware.length + const licensedCount = useMemo( + () => filteredSoftware.filter((i) => i.licensed).length, + [filteredSoftware] + ) + const needsUpdateCount = useMemo( + () => filteredSoftware.filter((i) => i.needsUpdate).length, + [filteredSoftware] + ) + + const handleExport = useCallback(() => { + const csv = exportToCsv(filteredSoftware) + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `inventario-software-${new Date().toISOString().slice(0, 10)}.csv` + a.click() + URL.revokeObjectURL(url) + }, [filteredSoftware]) + + return ( +
+
+
+

+ Inventario de Software +

+

+ Software instalado en los dispositivos +

+
+
+ setSearch(e.target.value)} + placeholder="Buscar software..." + className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 transition-colors focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20" + /> + +
+
+ +
+ + + +
+ +
+ +
+
+ ) +} diff --git a/src/app/(dashboard)/terminal/page.tsx b/src/app/(dashboard)/terminal/page.tsx new file mode 100644 index 0000000..847e568 --- /dev/null +++ b/src/app/(dashboard)/terminal/page.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useState, useCallback } from 'react' +import { useSelectedClient } from '@/components/providers/SelectedClientProvider' +import { trpc } from '@/lib/trpc-client' +import TerminalWindow from '@/components/terminal/TerminalWindow' +import QuickCommands from '@/components/terminal/QuickCommands' + +const MOCK_RESPONSES: Record = { + systeminfo: 'Nombre del host: PC-RECEPCION-01\nOS: Microsoft Windows 11 Pro\n...', + ipconfig: 'Adaptador Ethernet: 192.168.1.10\nMáscara de subred: 255.255.255.0\n...', + tasklist: 'Nombre de imagen PID Nombre de sesión\nchrome.exe 1234 Console\n...', + netstat: 'Conexiones activas\n Proto Dirección local Dirección remota\n TCP 0.0.0.0:443 LISTENING\n...', + 'CPU Info': 'Procesador: Intel Core i7-10700\nUso: 23%\n...', + 'RAM Info': 'Memoria total: 16 GB\nEn uso: 8.2 GB\nDisponible: 7.8 GB\n...', + 'dir C:\\': ' Volume in drive C is OS\n Directory of C:\\\n archivos...\n...', + hostname: 'PC-RECEPCION-01', +} + +function getMockResponse(cmd: string): string { + return MOCK_RESPONSES[cmd] ?? `Comando ejecutado: ${cmd}\n(Salida simulada)` +} + +export default function TerminalPage() { + const { selectedClientId } = useSelectedClient() + const clienteId = selectedClientId ?? undefined + + const listQuery = trpc.equipos.list.useQuery( + { clienteId, limit: 100 }, + { refetchOnWindowFocus: false } + ) + + const devices = (listQuery.data?.dispositivos ?? []).map((d) => ({ + id: d.id, + nombre: d.nombre, + })) + + const [selectedDeviceId, setSelectedDeviceId] = useState('') + const [outputLines, setOutputLines] = useState([]) + const [command, setCommand] = useState('') + + const selectedDevice = selectedDeviceId + ? devices.find((d) => d.id === selectedDeviceId) + : null + const connectedDeviceName = selectedDevice?.nombre ?? null + const isConnected = !!selectedDeviceId + + const handleSendCommand = useCallback(() => { + const trimmed = command.trim() + if (!trimmed) return + + const response = getMockResponse(trimmed) + const newLines = [`$ ${trimmed}`, ...response.split('\n').map((line) => `> ${line}`)] + setOutputLines((prev) => [...prev, ...newLines]) + setCommand('') + }, [command]) + + const handleClear = useCallback(() => { + setOutputLines([]) + }, []) + + const handleCopy = useCallback(() => { + const text = outputLines.join('\n') + if (text) { + void navigator.clipboard.writeText(text) + } + }, [outputLines]) + + const handleQuickCommand = useCallback((cmd: string) => { + setCommand(cmd) + }, []) + + return ( +
+
+
+

+ Terminal Remoto +

+

+ Ejecuta comandos en dispositivos conectados +

+
+
+ +
+
+ + + + +
+ ) +} diff --git a/src/components/files/FileExplorerContainer.tsx b/src/components/files/FileExplorerContainer.tsx new file mode 100644 index 0000000..fee8bac --- /dev/null +++ b/src/components/files/FileExplorerContainer.tsx @@ -0,0 +1,91 @@ +'use client' + +import { useRef } from 'react' +import { FolderOpen, Upload, RefreshCw } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { FileItem } from './FileRow' +import FileTable from './FileTable' + +interface FileExplorerContainerProps { + selectedDeviceName: string | null + currentPath: string + files: FileItem[] + onFolderClick?: (name: string) => void + onRefresh?: () => void + onUpload?: () => void +} + +export default function FileExplorerContainer({ + selectedDeviceName, + currentPath, + files, + onFolderClick, + onRefresh, + onUpload, +}: FileExplorerContainerProps) { + const pathRef = useRef(null) + const hasDevice = !!selectedDeviceName + + return ( +
+
+
+ +
+ {currentPath} +
+
+
+ + +
+
+ +
+ {!hasDevice ? ( +
+ +

+ Selecciona un dispositivo para explorar archivos +

+
+ ) : ( + + )} +
+
+ ) +} diff --git a/src/components/files/FileRow.tsx b/src/components/files/FileRow.tsx new file mode 100644 index 0000000..7598135 --- /dev/null +++ b/src/components/files/FileRow.tsx @@ -0,0 +1,50 @@ +'use client' + +import { Folder, FileText } from 'lucide-react' +import { cn } from '@/lib/utils' + +export interface FileItem { + id: string + name: string + type: 'folder' | 'file' + size: string | null + modifiedAt: string +} + +interface FileRowProps { + file: FileItem + onFolderClick?: (name: string) => void +} + +export default function FileRow({ file, onFolderClick }: FileRowProps) { + const isFolder = file.type === 'folder' + + return ( + isFolder && onFolderClick?.(file.name)} + > + +
+ {isFolder ? ( + + ) : ( + + )} + {file.name} +
+ + + {file.size ?? '—'} + + + {isFolder ? 'Carpeta' : 'Archivo'} + + {file.modifiedAt} + + ) +} diff --git a/src/components/files/FileTable.tsx b/src/components/files/FileTable.tsx new file mode 100644 index 0000000..7b6aaba --- /dev/null +++ b/src/components/files/FileTable.tsx @@ -0,0 +1,35 @@ +'use client' + +import type { FileItem } from './FileRow' +import FileRow from './FileRow' + +interface FileTableProps { + files: FileItem[] + onFolderClick?: (name: string) => void +} + +export default function FileTable({ files, onFolderClick }: FileTableProps) { + return ( +
+ + + + + + + + + + + {files.map((file) => ( + + ))} + +
NombreTamañoTipoFecha modificación
+
+ ) +} diff --git a/src/components/headwind/CorporateAppRow.tsx b/src/components/headwind/CorporateAppRow.tsx new file mode 100644 index 0000000..84bacd2 --- /dev/null +++ b/src/components/headwind/CorporateAppRow.tsx @@ -0,0 +1,28 @@ +'use client' + +import { cn } from '@/lib/utils' +import type { AppDeployment } from '@/mocks/mdmDashboardData' + +interface CorporateAppRowProps { + app: AppDeployment +} + +export default function CorporateAppRow({ app }: CorporateAppRowProps) { + const isIncomplete = app.deployed < app.total + return ( +
+
+

{app.name}

+

v{app.version}

+
+ + {app.deployed}/{app.total} dispositivos + +
+ ) +} diff --git a/src/components/headwind/CorporateAppsList.tsx b/src/components/headwind/CorporateAppsList.tsx new file mode 100644 index 0000000..7dc547e --- /dev/null +++ b/src/components/headwind/CorporateAppsList.tsx @@ -0,0 +1,33 @@ +'use client' + +import type { AppDeployment } from '@/mocks/mdmDashboardData' +import CorporateAppRow from './CorporateAppRow' + +interface CorporateAppsListProps { + apps: AppDeployment[] +} + +export default function CorporateAppsList({ apps }: CorporateAppsListProps) { + return ( +
+
+

+ Apps Corporativas +

+
+
+ {apps.length === 0 ? ( +
+ Sin apps desplegadas +
+ ) : ( + apps.map((app) => ( +
+ +
+ )) + )} +
+
+ ) +} diff --git a/src/components/headwind/DeviceItem.tsx b/src/components/headwind/DeviceItem.tsx new file mode 100644 index 0000000..dbc6158 --- /dev/null +++ b/src/components/headwind/DeviceItem.tsx @@ -0,0 +1,38 @@ +'use client' + +import { cn } from '@/lib/utils' +import type { Device, DeviceStatus } from '@/mocks/mdmDashboardData' + +const statusConfig: Record = { + online: { dot: 'bg-green-500', text: 'text-green-400' }, + offline: { dot: 'bg-red-500', text: 'text-red-400' }, + kiosk: { dot: 'bg-blue-500', text: 'text-blue-400' }, +} + +const statusLabel: Record = { + online: 'Online', + offline: 'Offline', + kiosk: 'Kiosk', +} + +interface DeviceItemProps { + device: Device +} + +export default function DeviceItem({ device }: DeviceItemProps) { + const config = statusConfig[device.status] + return ( +
+
+

{device.name}

+

+ Android {device.androidVersion} · Batería {device.batteryPercent}% +

+
+ + + {statusLabel[device.status]} + +
+ ) +} diff --git a/src/components/headwind/HeadwindMetricCard.tsx b/src/components/headwind/HeadwindMetricCard.tsx new file mode 100644 index 0000000..6e293e0 --- /dev/null +++ b/src/components/headwind/HeadwindMetricCard.tsx @@ -0,0 +1,45 @@ +'use client' + +import { cn } from '@/lib/utils' + +type AccentColor = 'green' | 'blue' | 'cyan' | 'amber' + +interface HeadwindMetricCardProps { + label: string + value: string | number + subtitle: string + accent?: AccentColor +} + +const accentColors: Record = { + green: 'text-emerald-400', + blue: 'text-blue-400', + cyan: 'text-cyan-400', + amber: 'text-amber-400', +} + +export default function HeadwindMetricCard({ + label, + value, + subtitle, + accent = 'cyan', +}: HeadwindMetricCardProps) { + return ( +
+ + {label} + + + {value} + + {subtitle} +
+ ) +} diff --git a/src/components/headwind/MobileDeviceList.tsx b/src/components/headwind/MobileDeviceList.tsx new file mode 100644 index 0000000..df7fa36 --- /dev/null +++ b/src/components/headwind/MobileDeviceList.tsx @@ -0,0 +1,33 @@ +'use client' + +import type { Device } from '@/mocks/mdmDashboardData' +import DeviceItem from './DeviceItem' + +interface MobileDeviceListProps { + devices: Device[] +} + +export default function MobileDeviceList({ devices }: MobileDeviceListProps) { + return ( +
+
+

+ Dispositivos Móviles +

+
+
+ {devices.length === 0 ? ( +
+ Sin dispositivos +
+ ) : ( + devices.map((device) => ( +
+ +
+ )) + )} +
+
+ ) +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 17a9634..6042ff8 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -67,13 +67,13 @@ const menuConfig: SidebarMenuSection[] = [ items: [ { label: 'LibreNMS', - href: '/configuracion', + href: '/librenms', icon: , badge: { type: 'green', value: 'OK' }, }, { label: 'Headwind MDM', - href: '/configuracion', + href: '/headwind', icon: , badge: { type: 'blue', value: 12 }, }, @@ -102,11 +102,12 @@ const menuConfig: SidebarMenuSection[] = [ interface SidebarProps { activeAlertsCount?: number devicesCount?: number + sessionsCount?: number open?: boolean onClose?: () => void } -export default function Sidebar({ activeAlertsCount, devicesCount, open = false, onClose }: SidebarProps) { +export default function Sidebar({ activeAlertsCount, devicesCount, sessionsCount, open = false, onClose }: SidebarProps) { const pathname = usePathname() const isActive = (href: string) => { @@ -122,6 +123,10 @@ export default function Sidebar({ activeAlertsCount, devicesCount, open = false, if (item.href === '/devices' && devicesCount !== undefined) { return { type: 'red', value: devicesCount } } + if (item.href === '/sesiones' && sessionsCount !== undefined) { + if (sessionsCount === 0) return undefined + return { type: 'red', value: sessionsCount } + } return item.badge } diff --git a/src/components/librenms/AlertItem.tsx b/src/components/librenms/AlertItem.tsx new file mode 100644 index 0000000..e4642f5 --- /dev/null +++ b/src/components/librenms/AlertItem.tsx @@ -0,0 +1,33 @@ +'use client' + +import { cn } from '@/lib/utils' + +export type AlertSeverity = 'critical' | 'warning' | 'info' + +export interface SnmpAlert { + id: string + title: string + detail: string + severity: AlertSeverity +} + +const severityStyles: Record = { + critical: 'text-red-400', + warning: 'text-yellow-400', + info: 'text-blue-400', +} + +interface AlertItemProps { + alert: SnmpAlert +} + +export default function AlertItem({ alert }: AlertItemProps) { + return ( +
+

+ {alert.title} +

+

{alert.detail}

+
+ ) +} diff --git a/src/components/librenms/AlertList.tsx b/src/components/librenms/AlertList.tsx new file mode 100644 index 0000000..e42d9d6 --- /dev/null +++ b/src/components/librenms/AlertList.tsx @@ -0,0 +1,31 @@ +'use client' + +import type { SnmpAlert } from './AlertItem' +import AlertItem from './AlertItem' + +interface AlertListProps { + alerts: SnmpAlert[] +} + +export default function AlertList({ alerts }: AlertListProps) { + return ( +
+
+

+ Alertas SNMP Recientes +

+
+
+ {alerts.length === 0 ? ( +
+ Sin alertas +
+ ) : ( + alerts.map((alert) => ( + + )) + )} +
+
+ ) +} diff --git a/src/components/librenms/DeviceList.tsx b/src/components/librenms/DeviceList.tsx new file mode 100644 index 0000000..660277e --- /dev/null +++ b/src/components/librenms/DeviceList.tsx @@ -0,0 +1,33 @@ +'use client' + +import type { NetworkDevice } from './DeviceRow' +import DeviceRow from './DeviceRow' + +interface DeviceListProps { + devices: NetworkDevice[] +} + +export default function DeviceList({ devices }: DeviceListProps) { + return ( +
+
+

+ Dispositivos de Red +

+
+
+ {devices.length === 0 ? ( +
+ Sin dispositivos +
+ ) : ( + devices.map((device) => ( +
+ +
+ )) + )} +
+
+ ) +} diff --git a/src/components/librenms/DeviceRow.tsx b/src/components/librenms/DeviceRow.tsx new file mode 100644 index 0000000..d053d80 --- /dev/null +++ b/src/components/librenms/DeviceRow.tsx @@ -0,0 +1,37 @@ +'use client' + +import type { DeviceStatus } from './StatusBadge' +import StatusBadge from './StatusBadge' + +export interface NetworkDevice { + id: string + name: string + model?: string + status: DeviceStatus +} + +const statusLabel: Record = { + online: 'Online', + warning: 'Warning', + critical: 'Critical', +} + +interface DeviceRowProps { + device: NetworkDevice +} + +export default function DeviceRow({ device }: DeviceRowProps) { + return ( +
+
+ {device.name} + {device.model && ( + – {device.model} + )} +
+ +
+ ) +} diff --git a/src/components/librenms/LibrenmsMetricCard.tsx b/src/components/librenms/LibrenmsMetricCard.tsx new file mode 100644 index 0000000..24dfcc2 --- /dev/null +++ b/src/components/librenms/LibrenmsMetricCard.tsx @@ -0,0 +1,45 @@ +'use client' + +import { cn } from '@/lib/utils' + +type AccentColor = 'green' | 'cyan' | 'yellow' | 'blue' + +interface LibrenmsMetricCardProps { + label: string + value: string | number + subtitle: string + accent?: AccentColor +} + +const accentColors: Record = { + green: 'text-emerald-400', + cyan: 'text-cyan-400', + yellow: 'text-amber-400', + blue: 'text-blue-400', +} + +export default function LibrenmsMetricCard({ + label, + value, + subtitle, + accent = 'cyan', +}: LibrenmsMetricCardProps) { + return ( +
+ + {label} + + + {value} + + {subtitle} +
+ ) +} diff --git a/src/components/librenms/LibrenmsRow.tsx b/src/components/librenms/LibrenmsRow.tsx new file mode 100644 index 0000000..109eb09 --- /dev/null +++ b/src/components/librenms/LibrenmsRow.tsx @@ -0,0 +1,49 @@ +'use client' + +import { cn } from '@/lib/utils' + +export interface LibrenmsNodeItem { + id: string + name: string + ip: string + type: string + status: 'up' | 'down' | 'warning' + lastUpdate: string +} + +interface LibrenmsRowProps { + item: LibrenmsNodeItem +} + +const statusConfig = { + up: { label: 'En línea', dot: 'bg-emerald-500' }, + down: { label: 'Fuera de línea', dot: 'bg-red-500' }, + warning: { label: 'Alerta', dot: 'bg-amber-500' }, +} + +export default function LibrenmsRow({ item }: LibrenmsRowProps) { + const status = statusConfig[item.status] + + return ( + + + {item.name} + + + {item.ip} + + + + {item.type} + + + +
+ + {status.label} +
+ + {item.lastUpdate} + + ) +} diff --git a/src/components/librenms/StatusBadge.tsx b/src/components/librenms/StatusBadge.tsx new file mode 100644 index 0000000..9c1aff7 --- /dev/null +++ b/src/components/librenms/StatusBadge.tsx @@ -0,0 +1,26 @@ +'use client' + +import { cn } from '@/lib/utils' + +export type DeviceStatus = 'online' | 'warning' | 'critical' + +interface StatusBadgeProps { + status: DeviceStatus + label: string +} + +const statusConfig: Record = { + online: { dot: 'bg-green-500', text: 'text-green-400' }, + warning: { dot: 'bg-yellow-500', text: 'text-yellow-400' }, + critical: { dot: 'bg-red-500', text: 'text-red-400' }, +} + +export default function StatusBadge({ status, label }: StatusBadgeProps) { + const config = statusConfig[status] + return ( + + + {label} + + ) +} diff --git a/src/components/performance/LineChart.tsx b/src/components/performance/LineChart.tsx new file mode 100644 index 0000000..c6cf1b3 --- /dev/null +++ b/src/components/performance/LineChart.tsx @@ -0,0 +1,57 @@ +'use client' + +import { cn } from '@/lib/utils' + +interface LineChartProps { + className?: string + height?: number + data?: number[] +} + +export default function LineChart({ + className, + height = 160, + data = [], +}: LineChartProps) { + const points = data.length >= 2 + ? data + .map((v, i) => { + const x = (i / Math.max(1, data.length - 1)) * 100 + const y = 100 - Math.min(100, Math.max(0, v)) + return `${x},${y}` + }) + .join(' ') + : '0,100 100,100' + + return ( +
+ {data.length >= 2 ? ( + + + + ) : ( +
+ — +
+ )} +
+ ) +} diff --git a/src/components/performance/MetricCard.tsx b/src/components/performance/MetricCard.tsx new file mode 100644 index 0000000..140d14b --- /dev/null +++ b/src/components/performance/MetricCard.tsx @@ -0,0 +1,72 @@ +'use client' + +import { cn } from '@/lib/utils' +import LineChart from './LineChart' + +export interface MetricCardFooterRow { + label: string + value: string +} + +interface MetricCardProps { + title: string + value: string + valueSuffix?: string + footerStats: MetricCardFooterRow[] + chartData?: number[] + highUsage?: boolean +} + +export default function MetricCard({ + title, + value, + valueSuffix, + footerStats, + chartData, + highUsage = false, +}: MetricCardProps) { + return ( +
+
+ {title} + + {value} + {valueSuffix != null && ( + + {valueSuffix} + + )} + +
+ +
+ +
+ +
+ {footerStats.map((row) => ( +
+ {row.label} + + {row.value} + +
+ ))} +
+
+ ) +} diff --git a/src/components/performance/ProcessRow.tsx b/src/components/performance/ProcessRow.tsx new file mode 100644 index 0000000..6283c0c --- /dev/null +++ b/src/components/performance/ProcessRow.tsx @@ -0,0 +1,36 @@ +'use client' + +import { cn } from '@/lib/utils' + +export interface ProcessItem { + id: string + name: string + pid: number + cpu: number + memory: string + state: string +} + +interface ProcessRowProps { + process: ProcessItem +} + +export default function ProcessRow({ process }: ProcessRowProps) { + return ( + + + {process.name} + + + {process.pid} + + + {process.cpu}% + + + {process.memory} + + {process.state} + + ) +} diff --git a/src/components/performance/ProcessTable.tsx b/src/components/performance/ProcessTable.tsx new file mode 100644 index 0000000..9954098 --- /dev/null +++ b/src/components/performance/ProcessTable.tsx @@ -0,0 +1,65 @@ +'use client' + +import type { ProcessItem } from './ProcessRow' +import ProcessRow from './ProcessRow' + +interface ProcessTableProps { + processes: ProcessItem[] + noDevice?: boolean +} + +export default function ProcessTable({ processes, noDevice }: ProcessTableProps) { + if (noDevice) { + return ( +
+
+

+ Procesos Activos (Top 10 por CPU) +

+
+
+ Selecciona un dispositivo +
+
+ ) + } + + return ( +
+
+

+ Procesos Activos (Top 10 por CPU) +

+
+
+ + + + + + + + + + + + {processes.length === 0 ? ( + + + + ) : ( + processes.map((p) => ( + + )) + )} + +
ProcesoPIDCPU %MemoriaEstado
+ Sin datos +
+
+
+ ) +} diff --git a/src/components/reportes/DateRangeFilter.tsx b/src/components/reportes/DateRangeFilter.tsx new file mode 100644 index 0000000..2345fa1 --- /dev/null +++ b/src/components/reportes/DateRangeFilter.tsx @@ -0,0 +1,60 @@ +'use client' + +export interface DateRangeValue { + desde: string + hasta: string +} + +interface DateRangeFilterProps { + value: DateRangeValue + onChange: (value: DateRangeValue) => void +} + +export default function DateRangeFilter({ value, onChange }: DateRangeFilterProps) { + const handleDesdeChange = (e: React.ChangeEvent) => { + const desde = e.target.value + const hasta = value.hasta && desde > value.hasta ? desde : value.hasta + onChange({ desde, hasta }) + } + + const handleHastaChange = (e: React.ChangeEvent) => { + const hasta = e.target.value + onChange({ ...value, hasta }) + } + + const isHastaBeforeDesde = + value.desde && value.hasta && value.hasta < value.desde + + return ( +
+ + + {isHastaBeforeDesde && ( +

+ La fecha Hasta no puede ser anterior a Desde +

+ )} +
+ ) +} diff --git a/src/components/reportes/ReportCard.tsx b/src/components/reportes/ReportCard.tsx new file mode 100644 index 0000000..395a7fa --- /dev/null +++ b/src/components/reportes/ReportCard.tsx @@ -0,0 +1,62 @@ +'use client' + +import { cn } from '@/lib/utils' +import { FileText, FileSpreadsheet } from 'lucide-react' + +interface ReportCardProps { + title: string + description: string + onExportPdf: () => void + onExportExcel: () => void + loading?: boolean +} + +export default function ReportCard({ + title, + description, + onExportPdf, + onExportExcel, + loading = false, +}: ReportCardProps) { + const btnBase = + 'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50' + const btnPrimary = + 'bg-cyan-600 text-white hover:bg-cyan-500' + const btnOutlined = + 'border border-white/10 text-gray-300 hover:bg-white/5 hover:text-gray-200' + + return ( +
+
+

{title}

+

{description}

+
+
+ + +
+
+ ) +} diff --git a/src/components/sessions/SessionCard.tsx b/src/components/sessions/SessionCard.tsx new file mode 100644 index 0000000..3a50bff --- /dev/null +++ b/src/components/sessions/SessionCard.tsx @@ -0,0 +1,95 @@ +'use client' + +import { Monitor, Terminal, FolderOpen } from 'lucide-react' +import { cn } from '@/lib/utils' + +export type SessionTypeLabel = 'Escritorio Remoto' | 'Terminal' | 'Archivos' + +export interface SessionCardProps { + id: string + deviceName: string + userEmail: string + sessionType: SessionTypeLabel + duration: string + onEnd?: (id: string) => void + isEnding?: boolean +} + +const typeConfig: Record< + SessionTypeLabel, + { icon: React.ReactNode; bgClass: string } +> = { + 'Escritorio Remoto': { + icon: , + bgClass: 'bg-cyan-500/15 text-cyan-400', + }, + Terminal: { + icon: , + bgClass: 'bg-emerald-500/15 text-emerald-400', + }, + Archivos: { + icon: , + bgClass: 'bg-amber-500/15 text-amber-400', + }, +} + +export default function SessionCard({ + id, + deviceName, + userEmail, + sessionType, + duration, + onEnd, + isEnding, +}: SessionCardProps) { + const config = typeConfig[sessionType] + + return ( +
+
+
+ {config.icon} +
+
+

+ {deviceName} +

+

{userEmail}

+
+
+ +
+ + {duration} + + {sessionType} +
+ +
+ +
+
+ ) +} diff --git a/src/components/software/SoftwareRow.tsx b/src/components/software/SoftwareRow.tsx new file mode 100644 index 0000000..4835fb9 --- /dev/null +++ b/src/components/software/SoftwareRow.tsx @@ -0,0 +1,48 @@ +'use client' + +import { cn } from '@/lib/utils' + +export interface SoftwareItem { + id: string + name: string + version: string + vendor: string + installations: number + lastUpdate: string + licensed?: boolean + needsUpdate?: boolean +} + +interface SoftwareRowProps { + item: SoftwareItem +} + +export default function SoftwareRow({ item }: SoftwareRowProps) { + return ( + + +
+ {item.name} + {item.licensed && ( + + Licencia + + )} + {item.needsUpdate && ( + + Actualizar + + )} +
+ + + {item.version} + + {item.vendor} + + {item.installations} + + {item.lastUpdate} + + ) +} diff --git a/src/components/software/SoftwareTable.tsx b/src/components/software/SoftwareTable.tsx new file mode 100644 index 0000000..1e4f1bd --- /dev/null +++ b/src/components/software/SoftwareTable.tsx @@ -0,0 +1,42 @@ +'use client' + +import type { SoftwareItem } from './SoftwareRow' +import SoftwareRow from './SoftwareRow' + +interface SoftwareTableProps { + items: SoftwareItem[] +} + +export default function SoftwareTable({ items }: SoftwareTableProps) { + return ( +
+ + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((item) => ( + + )) + )} + +
NombreVersiónEditorInstalacionesÚltima actualización
+ No hay resultados +
+
+ ) +} diff --git a/src/components/software/SummaryMetricCard.tsx b/src/components/software/SummaryMetricCard.tsx new file mode 100644 index 0000000..d0250f3 --- /dev/null +++ b/src/components/software/SummaryMetricCard.tsx @@ -0,0 +1,31 @@ +'use client' + +import { cn } from '@/lib/utils' + +interface SummaryMetricCardProps { + value: number + label: string + className?: string +} + +export default function SummaryMetricCard({ + value, + label, + className, +}: SummaryMetricCardProps) { + return ( +
+ + {value} + + {label} +
+ ) +} diff --git a/src/components/terminal/QuickCommands.tsx b/src/components/terminal/QuickCommands.tsx new file mode 100644 index 0000000..ec63234 --- /dev/null +++ b/src/components/terminal/QuickCommands.tsx @@ -0,0 +1,36 @@ +'use client' + +export interface QuickCommandsProps { + onSelectCommand: (command: string) => void +} + +const COMMANDS = [ + 'systeminfo', + 'ipconfig', + 'tasklist', + 'netstat', + 'CPU Info', + 'RAM Info', + 'dir C:\\', + 'hostname', +] + +export default function QuickCommands({ onSelectCommand }: QuickCommandsProps) { + return ( +
+

Comandos Rápidos

+
+ {COMMANDS.map((cmd) => ( + + ))} +
+
+ ) +} diff --git a/src/components/terminal/TerminalWindow.tsx b/src/components/terminal/TerminalWindow.tsx new file mode 100644 index 0000000..7b69199 --- /dev/null +++ b/src/components/terminal/TerminalWindow.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useRef, useEffect } from 'react' +import { Send, Copy } from 'lucide-react' +import { cn } from '@/lib/utils' + +export interface TerminalWindowProps { + connectedDeviceName: string | null + outputLines: string[] + command: string + onCommandChange: (value: string) => void + onSendCommand: () => void + onClear: () => void + onCopy: () => void + disabled?: boolean +} + +const ASCII_PLACEHOLDER = ` + __ __ _ ____ _ _ +| \\/ | ___ ___| |__ / ___|___ _ __ | |_ _ __ __ _| | +| |\\/| |/ _ \\/ __| '_ \\| | / _ \\ '_ \\| __| '__/ _\` | | +| | | | __/\\__ \\ | | | |__| __/ | | | |_| | | (_| | | +|_| |_|\\___||___/_| |_|\\____\\___|_| |_|\\__|_| \\__,_|_| +` + +export default function TerminalWindow({ + connectedDeviceName, + outputLines, + command, + onCommandChange, + onSendCommand, + onClear, + onCopy, + disabled = false, +}: TerminalWindowProps) { + const outputEndRef = useRef(null) + + useEffect(() => { + outputEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [outputLines]) + + const isEmpty = outputLines.length === 0 + const showPlaceholder = !connectedDeviceName + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + onSendCommand() + } + } + + return ( +
+
+
+
+ + + +
+ + bash — {connectedDeviceName ?? 'No conectado'} + +
+
+ + +
+
+ +
+ {showPlaceholder ? ( +
+
{ASCII_PLACEHOLDER}
+

+ Selecciona un dispositivo para iniciar una sesión de terminal. +

+
+ ) : isEmpty ? ( +
+
+
+ ) : ( +
+ {outputLines.map((line, i) => ( +
') + ? 'text-gray-500' + : 'text-gray-300' + )} + > + {line} +
+ ))} +
+
+ )} +
+ +
+
+ $ + onCommandChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Escribe un comando..." + disabled={disabled} + className={cn( + 'min-w-0 flex-1 bg-transparent font-mono text-gray-200 placeholder-gray-500', + 'focus:outline-none disabled:cursor-not-allowed disabled:opacity-60' + )} + /> + +
+
+
+ ) +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ca6a17f..008115d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -24,6 +24,14 @@ export function formatUptime(seconds: number): string { return `${minutes}m` } +export function formatDurationSeconds(seconds: number): string { + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = Math.floor(seconds % 60) + const pad = (n: number) => n.toString().padStart(2, '0') + return `${pad(h)}:${pad(m)}:${pad(s)}` +} + export function formatDate(date: Date | string): string { const d = new Date(date) return d.toLocaleDateString('es-MX', { diff --git a/src/mocks/mdmDashboardData.ts b/src/mocks/mdmDashboardData.ts new file mode 100644 index 0000000..9100bd9 --- /dev/null +++ b/src/mocks/mdmDashboardData.ts @@ -0,0 +1,58 @@ +export type DeviceStatus = 'online' | 'offline' | 'kiosk' + +export interface Device { + id: string + name: string + androidVersion: string + batteryPercent: number + status: DeviceStatus +} + +export interface AppDeployment { + id: string + name: string + version: string + deployed: number + total: number +} + +export interface DashboardStats { + totalAndroidDevices: number + deployedApps: number + activePolicies: number + averageBatteryPercent: number +} + +export const MOCK_DASHBOARD_STATS: DashboardStats = { + totalAndroidDevices: 12, + deployedApps: 8, + activePolicies: 5, + averageBatteryPercent: 78, +} + +export const MOCK_DEVICES: Device[] = [ + { id: '1', name: 'Samsung Galaxy A54 – Ventas01', androidVersion: '14', batteryPercent: 92, status: 'online' }, + { id: '2', name: 'Samsung Galaxy A54 – Ventas02', androidVersion: '14', batteryPercent: 45, status: 'online' }, + { id: '3', name: 'Xiaomi Redmi Note 12 – Almacén', androidVersion: '13', batteryPercent: 100, status: 'kiosk' }, + { id: '4', name: 'Motorola Moto G – Reparto01', androidVersion: '13', batteryPercent: 12, status: 'offline' }, + { id: '5', name: 'Samsung Galaxy A34 – Oficina', androidVersion: '14', batteryPercent: 78, status: 'online' }, +] + +export const MOCK_APP_DEPLOYMENTS: AppDeployment[] = [ + { id: '1', name: 'App Corporativa Ventas', version: '2.1.0', deployed: 12, total: 12 }, + { id: '2', name: 'Headwind Kiosk', version: '1.4.2', deployed: 10, total: 12 }, + { id: '3', name: 'Authenticator', version: '6.2.1', deployed: 12, total: 12 }, + { id: '4', name: 'Microsoft Teams', version: '1416/1.0.0', deployed: 8, total: 12 }, +] + +export function getMdmDashboardData(): { + stats: DashboardStats + devices: Device[] + appDeployments: AppDeployment[] +} { + return { + stats: MOCK_DASHBOARD_STATS, + devices: MOCK_DEVICES, + appDeployments: MOCK_APP_DEPLOYMENTS, + } +} diff --git a/src/mocks/reportService.ts b/src/mocks/reportService.ts new file mode 100644 index 0000000..d81202e --- /dev/null +++ b/src/mocks/reportService.ts @@ -0,0 +1,100 @@ +export interface ReportFilters { + desde: string + hasta: string +} + +export interface InventoryReportItem { + id: string + nombre: string + tipo: string + cliente: string + ip: string + so: string + estado: string +} + +export interface InventoryReport { + periodo: { desde: string; hasta: string } + total: number + items: InventoryReportItem[] +} + +export interface ResourceUsageReportDevice { + dispositivo: string + cliente: string + cpuPromedio: number + memoriaPromedio: number + redPromedioMB: number +} + +export interface ResourceUsageReport { + periodo: { desde: string; hasta: string } + dispositivos: ResourceUsageReportDevice[] +} + +export interface AlertsReportItem { + id: string + fecha: string + titulo: string + severidad: string + estado: string + dispositivo: string + resueltoEn: string | null +} + +export interface AlertsReport { + periodo: { desde: string; hasta: string } + total: number + items: AlertsReportItem[] +} + +function toDateStr(d: Date): string { + return d.toISOString().split('T')[0] +} + +// TODO: replace with trpc.reportes.inventario (and optional date filter if backend supports it) +export async function fetchInventoryReport( + startDate: Date, + endDate: Date +): Promise { + await new Promise((r) => setTimeout(r, 300)) + return { + periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) }, + total: 42, + items: [ + { id: '1', nombre: 'PC-Oficina-01', tipo: 'PC', cliente: 'Cliente A', ip: '192.168.1.10', so: 'Windows 11', estado: 'Activo' }, + { id: '2', nombre: 'LAPTOP-Ventas-02', tipo: 'LAPTOP', cliente: 'Cliente A', ip: '192.168.1.22', so: 'Windows 11', estado: 'Activo' }, + { id: '3', nombre: 'SRV-DC-01', tipo: 'SERVIDOR', cliente: 'Cliente B', ip: '10.0.0.5', so: 'Windows Server 2022', estado: 'Activo' }, + ], + } +} + +export async function fetchResourceUsageReport( + startDate: Date, + endDate: Date +): Promise { + await new Promise((r) => setTimeout(r, 300)) + return { + periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) }, + dispositivos: [ + { dispositivo: 'PC-Oficina-01', cliente: 'Cliente A', cpuPromedio: 24, memoriaPromedio: 62, redPromedioMB: 120 }, + { dispositivo: 'LAPTOP-Ventas-02', cliente: 'Cliente A', cpuPromedio: 18, memoriaPromedio: 45, redPromedioMB: 85 }, + { dispositivo: 'SRV-DC-01', cliente: 'Cliente B', cpuPromedio: 12, memoriaPromedio: 78, redPromedioMB: 340 }, + ], + } +} + +export async function fetchAlertsReport( + startDate: Date, + endDate: Date +): Promise { + await new Promise((r) => setTimeout(r, 300)) + return { + periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) }, + total: 28, + items: [ + { id: '1', fecha: '2024-01-15T10:30:00Z', titulo: 'CPU alto', severidad: 'CRITICAL', estado: 'RESUELTA', dispositivo: 'PC-Oficina-01', resueltoEn: '2024-01-15T10:45:00Z' }, + { id: '2', fecha: '2024-01-15T09:00:00Z', titulo: 'Disco lleno', severidad: 'WARNING', estado: 'RESUELTA', dispositivo: 'SRV-DC-01', resueltoEn: '2024-01-15T11:00:00Z' }, + ], + } +} diff --git a/src/server/trpc/routers/clientes.router.ts b/src/server/trpc/routers/clientes.router.ts index 6d609d4..058bf20 100644 --- a/src/server/trpc/routers/clientes.router.ts +++ b/src/server/trpc/routers/clientes.router.ts @@ -162,6 +162,7 @@ export const clientesRouter = router({ dispositivosAlerta, alertasActivas, alertasCriticas, + sesionesActivas, ] = await Promise.all([ ctx.prisma.dispositivo.count({ where }), ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }), @@ -173,6 +174,12 @@ export const clientesRouter = router({ ctx.prisma.alerta.count({ where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' }, }), + ctx.prisma.sesionRemota.count({ + where: { + finalizadaEn: null, + ...(clienteId ? { dispositivo: { clienteId } } : {}), + }, + }), ]) return { @@ -182,7 +189,7 @@ export const clientesRouter = router({ dispositivosAlerta, alertasActivas, alertasCriticas, - sesionesActivas: 0, // TODO: implementar + sesionesActivas, } }), diff --git a/src/server/trpc/routers/index.ts b/src/server/trpc/routers/index.ts index 634a452..f7a0803 100644 --- a/src/server/trpc/routers/index.ts +++ b/src/server/trpc/routers/index.ts @@ -5,6 +5,7 @@ import { equiposRouter } from './equipos.router' import { celularesRouter } from './celulares.router' import { redRouter } from './red.router' import { alertasRouter } from './alertas.router' +import { sesionesRouter } from './sesiones.router' import { reportesRouter } from './reportes.router' import { usuariosRouter } from './usuarios.router' import { configuracionRouter } from './configuracion.router' @@ -16,6 +17,7 @@ export const appRouter = router({ celulares: celularesRouter, red: redRouter, alertas: alertasRouter, + sesiones: sesionesRouter, reportes: reportesRouter, usuarios: usuariosRouter, configuracion: configuracionRouter, diff --git a/src/server/trpc/routers/sesiones.router.ts b/src/server/trpc/routers/sesiones.router.ts new file mode 100644 index 0000000..7c23b29 --- /dev/null +++ b/src/server/trpc/routers/sesiones.router.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { router, protectedProcedure } from '../trpc' + +export const sesionesRouter = router({ + list: protectedProcedure + .input( + z.object({ + clienteId: z.string().optional(), + limit: z.number().default(50), + }).optional() + ) + .query(async ({ ctx, input }) => { + const { clienteId, limit = 50 } = input || {} + + const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined + const where = { + finalizadaEn: null, + ...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}), + } + + const sessions = await ctx.prisma.sesionRemota.findMany({ + where, + include: { + usuario: { select: { id: true, email: true, nombre: true } }, + dispositivo: { select: { id: true, nombre: true, clienteId: true } }, + }, + orderBy: { iniciadaEn: 'desc' }, + take: limit, + }) + + return { sessions } + }), + + count: protectedProcedure + .input(z.object({ clienteId: z.string().optional() }).optional()) + .query(async ({ ctx, input }) => { + const clienteId = ctx.user.clienteId || input?.clienteId + + const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined + const where = { + finalizadaEn: null, + ...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}), + } + + return ctx.prisma.sesionRemota.count({ where }) + }), +})