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 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(
|
const clientsQuery = trpc.clientes.list.useQuery(
|
||||||
{ limit: 100 },
|
{ limit: 100 },
|
||||||
{ staleTime: 60 * 1000 }
|
{ staleTime: 60 * 1000 }
|
||||||
@@ -111,6 +117,7 @@ function DashboardContentInner({
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
activeAlertsCount={activeAlertsCount}
|
activeAlertsCount={activeAlertsCount}
|
||||||
devicesCount={devicesCount}
|
devicesCount={devicesCount}
|
||||||
|
sessionsCount={sessionsCount}
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
onClose={() => setSidebarOpen(false)}
|
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: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'LibreNMS',
|
label: 'LibreNMS',
|
||||||
href: '/configuracion',
|
href: '/librenms',
|
||||||
icon: <Network className="w-5 h-5" />,
|
icon: <Network className="w-5 h-5" />,
|
||||||
badge: { type: 'green', value: 'OK' },
|
badge: { type: 'green', value: 'OK' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Headwind MDM',
|
label: 'Headwind MDM',
|
||||||
href: '/configuracion',
|
href: '/headwind',
|
||||||
icon: <Smartphone className="w-5 h-5" />,
|
icon: <Smartphone className="w-5 h-5" />,
|
||||||
badge: { type: 'blue', value: 12 },
|
badge: { type: 'blue', value: 12 },
|
||||||
},
|
},
|
||||||
@@ -102,11 +102,12 @@ const menuConfig: SidebarMenuSection[] = [
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeAlertsCount?: number
|
activeAlertsCount?: number
|
||||||
devicesCount?: number
|
devicesCount?: number
|
||||||
|
sessionsCount?: number
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onClose?: () => void
|
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 pathname = usePathname()
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
@@ -122,6 +123,10 @@ export default function Sidebar({ activeAlertsCount, devicesCount, open = false,
|
|||||||
if (item.href === '/devices' && devicesCount !== undefined) {
|
if (item.href === '/devices' && devicesCount !== undefined) {
|
||||||
return { type: 'red', value: devicesCount }
|
return { type: 'red', value: devicesCount }
|
||||||
}
|
}
|
||||||
|
if (item.href === '/sesiones' && sessionsCount !== undefined) {
|
||||||
|
if (sessionsCount === 0) return undefined
|
||||||
|
return { type: 'red', value: sessionsCount }
|
||||||
|
}
|
||||||
return item.badge
|
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`
|
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 {
|
export function formatDate(date: Date | string): string {
|
||||||
const d = new Date(date)
|
const d = new Date(date)
|
||||||
return d.toLocaleDateString('es-MX', {
|
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,
|
dispositivosAlerta,
|
||||||
alertasActivas,
|
alertasActivas,
|
||||||
alertasCriticas,
|
alertasCriticas,
|
||||||
|
sesionesActivas,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
ctx.prisma.dispositivo.count({ where }),
|
ctx.prisma.dispositivo.count({ where }),
|
||||||
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
|
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
|
||||||
@@ -173,6 +174,12 @@ export const clientesRouter = router({
|
|||||||
ctx.prisma.alerta.count({
|
ctx.prisma.alerta.count({
|
||||||
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
|
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
|
||||||
}),
|
}),
|
||||||
|
ctx.prisma.sesionRemota.count({
|
||||||
|
where: {
|
||||||
|
finalizadaEn: null,
|
||||||
|
...(clienteId ? { dispositivo: { clienteId } } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -182,7 +189,7 @@ export const clientesRouter = router({
|
|||||||
dispositivosAlerta,
|
dispositivosAlerta,
|
||||||
alertasActivas,
|
alertasActivas,
|
||||||
alertasCriticas,
|
alertasCriticas,
|
||||||
sesionesActivas: 0, // TODO: implementar
|
sesionesActivas,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { equiposRouter } from './equipos.router'
|
|||||||
import { celularesRouter } from './celulares.router'
|
import { celularesRouter } from './celulares.router'
|
||||||
import { redRouter } from './red.router'
|
import { redRouter } from './red.router'
|
||||||
import { alertasRouter } from './alertas.router'
|
import { alertasRouter } from './alertas.router'
|
||||||
|
import { sesionesRouter } from './sesiones.router'
|
||||||
import { reportesRouter } from './reportes.router'
|
import { reportesRouter } from './reportes.router'
|
||||||
import { usuariosRouter } from './usuarios.router'
|
import { usuariosRouter } from './usuarios.router'
|
||||||
import { configuracionRouter } from './configuracion.router'
|
import { configuracionRouter } from './configuracion.router'
|
||||||
@@ -16,6 +17,7 @@ export const appRouter = router({
|
|||||||
celulares: celularesRouter,
|
celulares: celularesRouter,
|
||||||
red: redRouter,
|
red: redRouter,
|
||||||
alertas: alertasRouter,
|
alertas: alertasRouter,
|
||||||
|
sesiones: sesionesRouter,
|
||||||
reportes: reportesRouter,
|
reportes: reportesRouter,
|
||||||
usuarios: usuariosRouter,
|
usuarios: usuariosRouter,
|
||||||
configuracion: configuracionRouter,
|
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