Almost all sections with mock data

This commit is contained in:
2026-02-16 14:41:01 -06:00
parent 1761dcdfe8
commit 4235f640d9
43 changed files with 2782 additions and 4 deletions

View File

@@ -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<string, FileItem[]> = {
'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<string>('')
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 (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Explorador de Archivos
</h1>
<p className="mt-1 text-gray-400">
Navega y transfiere archivos remotamente
</p>
</div>
<div className="shrink-0">
<select
value={selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value)
setCurrentPath('C:\\')
}}
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
>
<option value="">-- Seleccionar dispositivo --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.nombre}
</option>
))}
</select>
</div>
</header>
<FileExplorerContainer
selectedDeviceName={selectedDeviceName}
currentPath={currentPath}
files={files}
onFolderClick={handleFolderClick}
onRefresh={handleRefresh}
onUpload={handleUpload}
/>
</div>
)
}

View File

@@ -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<DashboardStats>(initial.stats)
const [devices] = useState<Device[]>(initial.devices)
const [appDeployments] = useState<AppDeployment[]>(initial.appDeployments)
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold text-white">
Headwind MDM
</h1>
<p className="mt-1 text-gray-400">
Gestión de dispositivos móviles Android
</p>
</header>
<section
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
aria-label="Resumen"
>
<HeadwindMetricCard
label="Dispositivos Android"
value={stats.totalAndroidDevices}
subtitle="Total registrados"
accent="green"
/>
<HeadwindMetricCard
label="Apps desplegadas"
value={stats.deployedApps}
subtitle="En catálogo"
accent="blue"
/>
<HeadwindMetricCard
label="Políticas activas"
value={stats.activePolicies}
subtitle="Configuraciones aplicadas"
accent="cyan"
/>
<HeadwindMetricCard
label="Batería promedio"
value={`${stats.averageBatteryPercent}%`}
subtitle="Estado actual"
accent="amber"
/>
</section>
<section
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
aria-label="Dispositivos y apps"
>
<MobileDeviceList devices={devices} />
<CorporateAppsList apps={appDeployments} />
</section>
</div>
)
}

View File

@@ -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({
<Sidebar
activeAlertsCount={activeAlertsCount}
devicesCount={devicesCount}
sessionsCount={sessionsCount}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>

View File

@@ -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<NetworkDevice[]>(MOCK_DEVICES)
const [alerts] = useState<SnmpAlert[]>(MOCK_ALERTS)
const [metrics] = useState({
totalDevices: 28,
uptime: 99.7,
activeAlerts: 3,
trafficToday: 847,
})
return (
<div className="space-y-6">
{/* Page header */}
<header>
<h1 className="text-3xl font-bold text-white">
LibreNMS - Monitoreo de Red
</h1>
<p className="mt-1 text-gray-400">
SNMP, NetFlow y alertas de infraestructura
</p>
</header>
{/* KPI metric cards */}
<section
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
aria-label="Métricas principales"
>
<LibrenmsMetricCard
label="Dispositivos Red"
value={metrics.totalDevices}
subtitle="Switches, routers, APs"
accent="green"
/>
<LibrenmsMetricCard
label="Uptime Promedio"
value={`${metrics.uptime}%`}
subtitle="Últimos 30 días"
accent="cyan"
/>
<LibrenmsMetricCard
label="Alertas Activas"
value={metrics.activeAlerts}
subtitle="2 críticas, 1 warning"
accent="yellow"
/>
<LibrenmsMetricCard
label="Tráfico Total"
value={`${metrics.trafficToday} GB`}
subtitle="NetFlow hoy"
accent="blue"
/>
</section>
{/* Main content grid */}
<section
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
aria-label="Dispositivos y alertas"
>
<DeviceList devices={devices} />
<AlertList alerts={alerts} />
</section>
</div>
)
}

View File

@@ -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<string>('')
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<ProcessItem[]>([])
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 (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Rendimiento en Tiempo Real
</h1>
<p className="mt-1 text-gray-400">
Monitorea recursos del sistema
</p>
</div>
<div className="shrink-0">
<select
value={selectedDeviceId}
onChange={(e) => setSelectedDeviceId(e.target.value)}
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">-- Seleccionar dispositivo --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.nombre}
</option>
))}
</select>
</div>
</header>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<MetricCard
title="CPU"
value={hasDevice ? `${metrics.cpu}` : '—'}
valueSuffix="%"
footerStats={cpuFooter}
chartData={chartHistory.cpu}
highUsage={metrics.cpu > 80}
/>
<MetricCard
title="Memoria RAM"
value={hasDevice ? `${metrics.memory}` : '—'}
valueSuffix="%"
footerStats={ramFooter}
chartData={chartHistory.memory}
highUsage={metrics.memory > 80}
/>
<MetricCard
title="Disco"
value={hasDevice ? `${metrics.disk}` : '—'}
valueSuffix="%"
footerStats={diskFooter}
chartData={chartHistory.disk}
highUsage={metrics.disk > 80}
/>
<MetricCard
title="Red"
value={hasDevice ? `${metrics.network}` : '—'}
valueSuffix="Mbps"
footerStats={networkFooter}
chartData={chartHistory.network}
/>
</div>
<ProcessTable processes={processes} noDevice={!hasDevice} />
</div>
)
}

View File

@@ -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<DateRangeValue>(defaultDateRange)
const [loading, setLoading] = useState<string | null>(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 (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold text-white">Reportes</h1>
<p className="mt-1 text-gray-400">Generación de informes del sistema</p>
</header>
<section className="py-4">
<DateRangeFilter value={filters} onChange={setFilters} />
</section>
<section
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
aria-label="Reportes disponibles"
>
<ReportCard
title="Inventario de Equipos"
description="Listado completo de todos los dispositivos registrados con especificaciones técnicas y estado actual."
onExportPdf={handleInventoryPdf}
onExportExcel={handleInventoryExcel}
loading={loading?.startsWith('inventario') ?? false}
/>
<ReportCard
title="Uso de Recursos"
description="Estadísticas de consumo de CPU, memoria y red de todos los dispositivos en el periodo seleccionado."
onExportPdf={handleResourcePdf}
onExportExcel={handleResourceExcel}
loading={loading?.startsWith('recursos') ?? false}
/>
<ReportCard
title="Historial de Alertas"
description="Registro completo de todas las alertas generadas, incluyendo resolución y tiempo de respuesta."
onExportPdf={handleAlertsPdf}
onExportExcel={handleAlertsExcel}
loading={loading?.startsWith('alertas') ?? false}
/>
</section>
</div>
)
}

View File

@@ -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<string, SessionTypeLabel> = {
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<Record<string, number>>({})
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<string, number> = {}
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 (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
</header>
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
Cargando sesiones...
</div>
</div>
)
}
if (listQuery.isError) {
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
</header>
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-12 text-center text-red-400">
Error al cargar sesiones. Intente de nuevo.
</div>
</div>
)
}
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
</header>
{sessions.length === 0 ? (
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
No hay sesiones activas.
</div>
) : (
<ul className="space-y-5">
{sessions.map((session) => {
const durationSeconds = liveDurations[session.id] ?? session.initialSeconds
const duration = formatDurationSeconds(durationSeconds)
const isEnding = endSessionMutation.isPending && endSessionMutation.variables?.sesionId === session.id
return (
<li key={session.id}>
<SessionCard
id={session.id}
deviceName={session.deviceName}
userEmail={session.userEmail}
sessionType={session.sessionType}
duration={duration}
onEnd={handleEndSession}
isEnding={isEnding}
/>
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -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<SoftwareItem[]>(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 (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Inventario de Software
</h1>
<p className="mt-1 text-gray-400">
Software instalado en los dispositivos
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={search}
onChange={(e) => 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"
/>
<button
type="button"
onClick={handleExport}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-cyan-500"
>
<Download className="h-4 w-4" />
Exportar
</button>
</div>
</header>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<SummaryMetricCard
value={uniqueCount}
label="Programas Únicos"
/>
<SummaryMetricCard
value={licensedCount}
label="Con Licencia"
/>
<SummaryMetricCard
value={needsUpdateCount}
label="Requieren Actualización"
/>
</div>
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<SoftwareTable items={filteredSoftware} />
</div>
</div>
)
}

View File

@@ -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<string, string> = {
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<string>('')
const [outputLines, setOutputLines] = useState<string[]>([])
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 (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Terminal Remoto
</h1>
<p className="mt-1 text-gray-400">
Ejecuta comandos en dispositivos conectados
</p>
</div>
<div className="shrink-0">
<select
value={selectedDeviceId}
onChange={(e) => setSelectedDeviceId(e.target.value)}
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
>
<option value="">-- Seleccionar dispositivo --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.nombre}
</option>
))}
</select>
</div>
</header>
<TerminalWindow
connectedDeviceName={connectedDeviceName}
outputLines={outputLines}
command={command}
onCommandChange={setCommand}
onSendCommand={handleSendCommand}
onClear={handleClear}
onCopy={handleCopy}
disabled={!isConnected}
/>
<QuickCommands onSelectCommand={handleQuickCommand} />
</div>
)
}

View File

@@ -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<HTMLDivElement>(null)
const hasDevice = !!selectedDeviceName
return (
<div
className={cn(
'flex h-[520px] min-h-[320px] flex-col overflow-hidden rounded-xl',
'border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'shadow-lg'
)}
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-white/10 bg-dark-200/80 px-4 py-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<button
type="button"
onClick={onUpload}
disabled={!hasDevice}
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
>
<Upload className="h-4 w-4" />
Subir
</button>
<div
ref={pathRef}
className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-white/10 bg-dark-300/80 px-3 py-2 font-mono text-sm text-gray-400"
>
{currentPath}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={onRefresh}
disabled={!hasDevice}
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw className="h-4 w-4" />
Actualizar
</button>
<button
type="button"
onClick={onUpload}
disabled={!hasDevice}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<Upload className="h-4 w-4" />
Subir
</button>
</div>
</div>
<div className="flex flex-1 flex-col overflow-hidden">
{!hasDevice ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
<FolderOpen className="h-16 w-16 text-gray-600" />
<p className="text-gray-500">
Selecciona un dispositivo para explorar archivos
</p>
</div>
) : (
<FileTable files={files} onFolderClick={onFolderClick} />
)}
</div>
</div>
)
}

View File

@@ -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 (
<tr
className={cn(
'border-b border-white/5 transition-colors last:border-b-0',
'hover:bg-white/5 cursor-pointer',
isFolder && 'cursor-pointer'
)}
onClick={() => isFolder && onFolderClick?.(file.name)}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
{isFolder ? (
<Folder className="h-5 w-5 shrink-0 text-amber-400/90" />
) : (
<FileText className="h-5 w-5 shrink-0 text-gray-500" />
)}
<span className="font-medium text-gray-200 truncate">{file.name}</span>
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-500 font-mono">
{file.size ?? '—'}
</td>
<td className="py-3 px-4 text-sm text-gray-500">
{isFolder ? 'Carpeta' : 'Archivo'}
</td>
<td className="py-3 px-4 text-sm text-gray-500">{file.modifiedAt}</td>
</tr>
)
}

View File

@@ -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 (
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead className="sticky top-0 z-10 bg-dark-300/95 backdrop-blur">
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="py-3 px-4">Nombre</th>
<th className="py-3 px-4 w-28">Tamaño</th>
<th className="py-3 px-4 w-24">Tipo</th>
<th className="py-3 px-4 w-36">Fecha modificación</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<FileRow
key={file.id}
file={file}
onFolderClick={onFolderClick}
/>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -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 (
<div className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5">
<div className="min-w-0">
<p className="font-medium text-gray-200">{app.name}</p>
<p className="text-sm text-gray-500">v{app.version}</p>
</div>
<span
className={cn(
'shrink-0 text-sm tabular-nums',
isIncomplete ? 'text-amber-400' : 'text-gray-400'
)}
>
{app.deployed}/{app.total} dispositivos
</span>
</div>
)
}

View File

@@ -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 (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Apps Corporativas
</h3>
</div>
<div className="divide-y divide-white/5">
{apps.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin apps desplegadas
</div>
) : (
apps.map((app) => (
<div key={app.id} className="px-4">
<CorporateAppRow app={app} />
</div>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import { cn } from '@/lib/utils'
import type { Device, DeviceStatus } from '@/mocks/mdmDashboardData'
const statusConfig: Record<DeviceStatus, { dot: string; text: string }> = {
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<DeviceStatus, string> = {
online: 'Online',
offline: 'Offline',
kiosk: 'Kiosk',
}
interface DeviceItemProps {
device: Device
}
export default function DeviceItem({ device }: DeviceItemProps) {
const config = statusConfig[device.status]
return (
<div className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5">
<div className="min-w-0 flex-1">
<p className="font-medium text-gray-200">{device.name}</p>
<p className="mt-0.5 text-sm text-gray-500">
Android {device.androidVersion} · Batería {device.batteryPercent}%
</p>
</div>
<span className={cn('ml-4 inline-flex items-center gap-1.5 text-sm shrink-0', config.text)}>
<span className={cn('h-2 w-2 rounded-full', config.dot)} />
{statusLabel[device.status]}
</span>
</div>
)
}

View File

@@ -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<AccentColor, string> = {
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 (
<div
className={cn(
'rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col justify-center px-5 py-6',
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
)}
>
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
{label}
</span>
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
{value}
</span>
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
</div>
)
}

View File

@@ -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 (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Dispositivos Móviles
</h3>
</div>
<div className="divide-y divide-white/5">
{devices.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin dispositivos
</div>
) : (
devices.map((device) => (
<div key={device.id} className="px-4">
<DeviceItem device={device} />
</div>
))
)}
</div>
</div>
)
}

View File

@@ -67,13 +67,13 @@ const menuConfig: SidebarMenuSection[] = [
items: [
{
label: 'LibreNMS',
href: '/configuracion',
href: '/librenms',
icon: <Network className="w-5 h-5" />,
badge: { type: 'green', value: 'OK' },
},
{
label: 'Headwind MDM',
href: '/configuracion',
href: '/headwind',
icon: <Smartphone className="w-5 h-5" />,
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
}

View File

@@ -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<AlertSeverity, string> = {
critical: 'text-red-400',
warning: 'text-yellow-400',
info: 'text-blue-400',
}
interface AlertItemProps {
alert: SnmpAlert
}
export default function AlertItem({ alert }: AlertItemProps) {
return (
<div className="border-b border-white/5 py-4 last:border-b-0">
<p className={cn('font-medium', severityStyles[alert.severity])}>
{alert.title}
</p>
<p className="mt-1 text-sm text-gray-500">{alert.detail}</p>
</div>
)
}

View File

@@ -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 (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Alertas SNMP Recientes
</h3>
</div>
<div className="px-4">
{alerts.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin alertas
</div>
) : (
alerts.map((alert) => (
<AlertItem key={alert.id} alert={alert} />
))
)}
</div>
</div>
)
}

View File

@@ -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 (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Dispositivos de Red
</h3>
</div>
<div className="divide-y divide-white/5">
{devices.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin dispositivos
</div>
) : (
devices.map((device) => (
<div key={device.id} className="px-4">
<DeviceRow device={device} />
</div>
))
)}
</div>
</div>
)
}

View File

@@ -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<DeviceStatus, string> = {
online: 'Online',
warning: 'Warning',
critical: 'Critical',
}
interface DeviceRowProps {
device: NetworkDevice
}
export default function DeviceRow({ device }: DeviceRowProps) {
return (
<div
className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5"
>
<div className="min-w-0">
<span className="font-medium text-gray-200">{device.name}</span>
{device.model && (
<span className="ml-2 text-sm text-gray-500"> {device.model}</span>
)}
</div>
<StatusBadge status={device.status} label={statusLabel[device.status]} />
</div>
)
}

View File

@@ -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<AccentColor, string> = {
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 (
<div
className={cn(
'rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col justify-center px-5 py-6',
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
)}
>
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
{label}
</span>
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
{value}
</span>
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
</div>
)
}

View File

@@ -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 (
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5 cursor-pointer">
<td className="py-3 px-4">
<span className="font-medium text-gray-200">{item.name}</span>
</td>
<td className="py-3 px-4 font-mono text-sm text-gray-400">
{item.ip}
</td>
<td className="py-3 px-4">
<span className="rounded-full bg-cyan-500/10 px-3 py-1 text-xs text-cyan-400">
{item.type}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className={cn('h-2 w-2 rounded-full', status.dot)} />
<span className="text-sm text-gray-400">{status.label}</span>
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
</tr>
)
}

View File

@@ -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<DeviceStatus, { dot: string; text: string }> = {
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 (
<span className={cn('inline-flex items-center gap-1.5 text-sm', config.text)}>
<span className={cn('h-2 w-2 shrink-0 rounded-full', config.dot)} />
{label}
</span>
)
}

View File

@@ -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 (
<div
className={cn(
'rounded-lg bg-dark-200/80 border border-white/5 overflow-hidden',
className
)}
style={{ height }}
>
{data.length >= 2 ? (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="w-full h-full text-cyan-500/30"
>
<polyline
fill="none"
stroke="currentColor"
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
className="transition-all duration-500"
/>
</svg>
) : (
<div className="flex h-full items-center justify-center text-gray-600 text-sm">
</div>
)}
</div>
)
}

View File

@@ -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 (
<div
className={cn(
'flex flex-col rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'transition-all duration-200 hover:border-white/20'
)}
>
<div className="flex shrink-0 items-center justify-between px-4 py-3 border-b border-white/5">
<span className="text-sm font-medium text-gray-400">{title}</span>
<span
className={cn(
'font-mono text-lg font-semibold tabular-nums transition-colors duration-300',
highUsage ? 'text-red-400' : 'text-cyan-400'
)}
>
{value}
{valueSuffix != null && (
<span className="text-sm font-normal text-gray-500 ml-0.5">
{valueSuffix}
</span>
)}
</span>
</div>
<div className="shrink-0 px-4 pt-3">
<LineChart height={150} data={chartData ?? []} />
</div>
<div className="shrink-0 space-y-1.5 px-4 py-3 border-t border-white/5">
{footerStats.map((row) => (
<div
key={row.label}
className="flex justify-between text-xs"
>
<span className="text-gray-500">{row.label}</span>
<span className="font-mono text-gray-400 tabular-nums">
{row.value}
</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -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 (
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
<td className="py-2.5 px-4 text-sm text-gray-200 truncate max-w-[200px]">
{process.name}
</td>
<td className="py-2.5 px-4 font-mono text-sm text-gray-400 tabular-nums">
{process.pid}
</td>
<td className="py-2.5 px-4 font-mono text-sm text-cyan-400 tabular-nums">
{process.cpu}%
</td>
<td className="py-2.5 px-4 font-mono text-sm text-gray-400">
{process.memory}
</td>
<td className="py-2.5 px-4 text-sm text-gray-500">{process.state}</td>
</tr>
)
}

View File

@@ -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 (
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
<div className="px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-medium text-gray-400">
Procesos Activos (Top 10 por CPU)
</h3>
</div>
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
Selecciona un dispositivo
</div>
</div>
)
}
return (
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
<div className="px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-medium text-gray-400">
Procesos Activos (Top 10 por CPU)
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="py-3 px-4">Proceso</th>
<th className="py-3 px-4 w-20">PID</th>
<th className="py-3 px-4 w-24">CPU %</th>
<th className="py-3 px-4 w-24">Memoria</th>
<th className="py-3 px-4 w-24">Estado</th>
</tr>
</thead>
<tbody>
{processes.length === 0 ? (
<tr>
<td
colSpan={5}
className="py-8 text-center text-sm text-gray-500"
>
Sin datos
</td>
</tr>
) : (
processes.map((p) => (
<ProcessRow key={p.id} process={p} />
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -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<HTMLInputElement>) => {
const desde = e.target.value
const hasta = value.hasta && desde > value.hasta ? desde : value.hasta
onChange({ desde, hasta })
}
const handleHastaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const hasta = e.target.value
onChange({ ...value, hasta })
}
const isHastaBeforeDesde =
value.desde && value.hasta && value.hasta < value.desde
return (
<div className="flex flex-wrap items-center gap-4">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
Desde
</span>
<input
type="date"
value={value.desde}
onChange={handleDesdeChange}
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
Hasta
</span>
<input
type="date"
value={value.hasta}
onChange={handleHastaChange}
min={value.desde || undefined}
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
/>
</label>
{isHastaBeforeDesde && (
<p className="text-sm text-amber-400" role="alert">
La fecha Hasta no puede ser anterior a Desde
</p>
)}
</div>
)
}

View File

@@ -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 (
<div
className={cn(
'rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col'
)}
>
<div className="border-b border-white/10 px-4 py-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-2 text-sm text-gray-400">{description}</p>
</div>
<div className="flex flex-wrap items-center gap-2 p-4">
<button
type="button"
onClick={onExportPdf}
disabled={loading}
className={cn(btnBase, btnPrimary)}
>
<FileText className="h-4 w-4" />
Exportar PDF
</button>
<button
type="button"
onClick={onExportExcel}
disabled={loading}
className={cn(btnBase, btnOutlined)}
>
<FileSpreadsheet className="h-4 w-4" />
Excel
</button>
</div>
</div>
)
}

View File

@@ -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: <Monitor className="h-5 w-5" />,
bgClass: 'bg-cyan-500/15 text-cyan-400',
},
Terminal: {
icon: <Terminal className="h-5 w-5" />,
bgClass: 'bg-emerald-500/15 text-emerald-400',
},
Archivos: {
icon: <FolderOpen className="h-5 w-5" />,
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 (
<div
className={cn(
'flex flex-col gap-4 rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5 transition-all duration-200',
'sm:flex-row sm:items-center sm:justify-between',
'hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20'
)}
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<div
className={cn(
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
config.bgClass
)}
>
{config.icon}
</div>
<div className="min-w-0">
<p className="text-lg font-semibold text-gray-100 truncate">
{deviceName}
</p>
<p className="text-sm text-gray-500 truncate">{userEmail}</p>
</div>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<span className="font-mono text-sm font-medium text-cyan-400 tabular-nums">
{duration}
</span>
<span className="text-xs text-gray-500">{sessionType}</span>
</div>
<div className="shrink-0 sm:pl-4">
<button
type="button"
onClick={() => onEnd?.(id)}
disabled={isEnding}
className={cn(
'rounded-lg border border-red-500/60 px-4 py-2 text-sm font-medium text-red-400',
'transition-colors hover:bg-red-500/15 hover:border-red-500/80',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isEnding ? 'Terminando…' : 'Terminar'}
</button>
</div>
</div>
)
}

View File

@@ -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 (
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-200">{item.name}</span>
{item.licensed && (
<span className="rounded bg-cyan-500/20 px-1.5 py-0.5 text-xs text-cyan-400">
Licencia
</span>
)}
{item.needsUpdate && (
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-400">
Actualizar
</span>
)}
</div>
</td>
<td className="py-3 px-4 font-mono text-sm text-gray-400">
{item.version}
</td>
<td className="py-3 px-4 text-sm text-gray-400">{item.vendor}</td>
<td className="py-3 px-4 font-mono text-sm text-cyan-400 tabular-nums">
{item.installations}
</td>
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
</tr>
)
}

View File

@@ -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 (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wide text-gray-400">
<th className="py-3 px-4">Nombre</th>
<th className="py-3 px-4 w-40">Versión</th>
<th className="py-3 px-4 w-48">Editor</th>
<th className="py-3 px-4 w-28">Instalaciones</th>
<th className="py-3 px-4 w-36">Última actualización</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td
colSpan={5}
className="py-12 text-center text-sm text-gray-500"
>
No hay resultados
</td>
</tr>
) : (
items.map((item) => (
<SoftwareRow key={item.id} item={item} />
))
)}
</tbody>
</table>
</div>
)
}

View File

@@ -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 (
<div
className={cn(
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col items-center justify-center py-6 px-4',
'transition-all duration-200 hover:border-white/20',
className
)}
>
<span className="text-4xl font-bold text-cyan-400 tabular-nums">
{value}
</span>
<span className="mt-2 text-sm text-gray-400">{label}</span>
</div>
)
}

View File

@@ -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 (
<section className="space-y-3">
<h3 className="text-sm font-medium text-gray-400">Comandos Rápidos</h3>
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
{COMMANDS.map((cmd) => (
<button
key={cmd}
type="button"
onClick={() => onSelectCommand(cmd)}
className="shrink-0 rounded-lg border border-white/10 bg-dark-300/80 px-4 py-2 font-mono text-xs text-gray-300 transition-colors hover:border-cyan-500/30 hover:bg-dark-200 hover:text-cyan-400"
>
{cmd}
</button>
))}
</div>
</section>
)
}

View File

@@ -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<HTMLDivElement>(null)
useEffect(() => {
outputEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [outputLines])
const isEmpty = outputLines.length === 0
const showPlaceholder = !connectedDeviceName
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
onSendCommand()
}
}
return (
<div
className={cn(
'flex h-[520px] min-h-[320px] flex-col overflow-hidden rounded-xl',
'border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'shadow-lg'
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-white/10 bg-dark-200/80 px-4 py-2.5">
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
<span className="h-3 w-3 rounded-full bg-red-500/90" />
<span className="h-3 w-3 rounded-full bg-amber-500/90" />
<span className="h-3 w-3 rounded-full bg-emerald-500/90" />
</div>
<span className="text-sm font-medium text-gray-400">
bash {connectedDeviceName ?? 'No conectado'}
</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onClear}
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
>
Limpiar
</button>
<button
type="button"
onClick={onCopy}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
>
<Copy className="h-3.5 w-3.5" />
Copiar
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 font-mono text-sm">
{showPlaceholder ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<pre className="whitespace-pre text-cyan-400/80">{ASCII_PLACEHOLDER}</pre>
<p className="mt-6 text-gray-500">
Selecciona un dispositivo para iniciar una sesión de terminal.
</p>
</div>
) : isEmpty ? (
<div className="flex h-full flex-col justify-end">
<div ref={outputEndRef} />
</div>
) : (
<div className="space-y-0.5">
{outputLines.map((line, i) => (
<div
key={i}
className={cn(
line.startsWith('$ ')
? 'text-cyan-400'
: line.startsWith('> ')
? 'text-gray-500'
: 'text-gray-300'
)}
>
{line}
</div>
))}
<div ref={outputEndRef} />
</div>
)}
</div>
<div className="shrink-0 border-t border-white/10 bg-dark-200/60 px-4 py-3">
<div className="flex items-center gap-2">
<span className="shrink-0 font-mono text-cyan-400">$</span>
<input
type="text"
value={command}
onChange={(e) => 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'
)}
/>
<button
type="button"
onClick={onSendCommand}
disabled={disabled}
className={cn(
'flex shrink-0 items-center justify-center rounded-lg bg-cyan-600 px-4 py-2',
'text-white transition-all hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -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', {

View File

@@ -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,
}
}

100
src/mocks/reportService.ts Normal file
View File

@@ -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<InventoryReport> {
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<ResourceUsageReport> {
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<AlertsReport> {
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' },
],
}
}

View File

@@ -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,
}
}),

View File

@@ -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,

View File

@@ -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 })
}),
})