Almost all sections with mock data
This commit is contained in:
134
src/app/(dashboard)/archivos/page.tsx
Normal file
134
src/app/(dashboard)/archivos/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
src/app/(dashboard)/headwind/page.tsx
Normal file
73
src/app/(dashboard)/headwind/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
105
src/app/(dashboard)/librenms/page.tsx
Normal file
105
src/app/(dashboard)/librenms/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
221
src/app/(dashboard)/rendimiento/page.tsx
Normal file
221
src/app/(dashboard)/rendimiento/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
src/app/(dashboard)/reportes/page.tsx
Normal file
185
src/app/(dashboard)/reportes/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
src/app/(dashboard)/sesiones/page.tsx
Normal file
141
src/app/(dashboard)/sesiones/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
207
src/app/(dashboard)/software/page.tsx
Normal file
207
src/app/(dashboard)/software/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
src/app/(dashboard)/terminal/page.tsx
Normal file
114
src/app/(dashboard)/terminal/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/components/files/FileExplorerContainer.tsx
Normal file
91
src/components/files/FileExplorerContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/components/files/FileRow.tsx
Normal file
50
src/components/files/FileRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/components/files/FileTable.tsx
Normal file
35
src/components/files/FileTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/components/headwind/CorporateAppRow.tsx
Normal file
28
src/components/headwind/CorporateAppRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/components/headwind/CorporateAppsList.tsx
Normal file
33
src/components/headwind/CorporateAppsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/components/headwind/DeviceItem.tsx
Normal file
38
src/components/headwind/DeviceItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/components/headwind/HeadwindMetricCard.tsx
Normal file
45
src/components/headwind/HeadwindMetricCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/components/headwind/MobileDeviceList.tsx
Normal file
33
src/components/headwind/MobileDeviceList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
33
src/components/librenms/AlertItem.tsx
Normal file
33
src/components/librenms/AlertItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
src/components/librenms/AlertList.tsx
Normal file
31
src/components/librenms/AlertList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/components/librenms/DeviceList.tsx
Normal file
33
src/components/librenms/DeviceList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/librenms/DeviceRow.tsx
Normal file
37
src/components/librenms/DeviceRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/components/librenms/LibrenmsMetricCard.tsx
Normal file
45
src/components/librenms/LibrenmsMetricCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/components/librenms/LibrenmsRow.tsx
Normal file
49
src/components/librenms/LibrenmsRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/librenms/StatusBadge.tsx
Normal file
26
src/components/librenms/StatusBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
src/components/performance/LineChart.tsx
Normal file
57
src/components/performance/LineChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
src/components/performance/MetricCard.tsx
Normal file
72
src/components/performance/MetricCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/performance/ProcessRow.tsx
Normal file
36
src/components/performance/ProcessRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
src/components/performance/ProcessTable.tsx
Normal file
65
src/components/performance/ProcessTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
src/components/reportes/DateRangeFilter.tsx
Normal file
60
src/components/reportes/DateRangeFilter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
src/components/reportes/ReportCard.tsx
Normal file
62
src/components/reportes/ReportCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
src/components/sessions/SessionCard.tsx
Normal file
95
src/components/sessions/SessionCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
src/components/software/SoftwareRow.tsx
Normal file
48
src/components/software/SoftwareRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/components/software/SoftwareTable.tsx
Normal file
42
src/components/software/SoftwareTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
src/components/software/SummaryMetricCard.tsx
Normal file
31
src/components/software/SummaryMetricCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/components/terminal/QuickCommands.tsx
Normal file
36
src/components/terminal/QuickCommands.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
src/components/terminal/TerminalWindow.tsx
Normal file
153
src/components/terminal/TerminalWindow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
58
src/mocks/mdmDashboardData.ts
Normal file
58
src/mocks/mdmDashboardData.ts
Normal 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
100
src/mocks/reportService.ts
Normal 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' },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
47
src/server/trpc/routers/sesiones.router.ts
Normal file
47
src/server/trpc/routers/sesiones.router.ts
Normal 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 })
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user