Compare commits
3 Commits
1761dcdfe8
...
DevEsteban
| Author | SHA1 | Date | |
|---|---|---|---|
| d999cf6298 | |||
| bd9bffb57c | |||
| 4235f640d9 |
147
src/app/(dashboard)/archivos/page.tsx
Normal file
147
src/app/(dashboard)/archivos/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
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 searchParams = useSearchParams()
|
||||
const deviceIdFromUrl = searchParams.get('deviceId')
|
||||
|
||||
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:\\')
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceIdFromUrl || !listQuery.data?.dispositivos?.length) return
|
||||
const exists = listQuery.data.dispositivos.some((d) => d.id === deviceIdFromUrl)
|
||||
if (exists) {
|
||||
setSelectedDeviceId(deviceIdFromUrl)
|
||||
setCurrentPath('C:\\')
|
||||
}
|
||||
}, [deviceIdFromUrl, listQuery.data?.dispositivos])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Search, Plus } from 'lucide-react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import DeviceCard, { type DeviceCardStatus } from '@/components/devices/DeviceCard'
|
||||
import AddDeviceModal from '@/components/devices/AddDeviceModal'
|
||||
import DeviceDetailModal from '@/components/devices/device-detail/DeviceDetailModal'
|
||||
|
||||
type StateFilter = '' | 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO'
|
||||
|
||||
@@ -61,6 +64,12 @@ export default function DevicesPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [stateFilter, setStateFilter] = useState<StateFilter>('')
|
||||
const [osFilter, setOsFilter] = useState('')
|
||||
const [addModalOpen, setAddModalOpen] = useState(false)
|
||||
const [detailDeviceId, setDetailDeviceId] = useState<string | null>(null)
|
||||
const [detailDeviceName, setDetailDeviceName] = useState<string>('')
|
||||
const [connectError, setConnectError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const listQuery = trpc.equipos.list.useQuery(
|
||||
{
|
||||
@@ -86,23 +95,81 @@ export default function DevicesPage() {
|
||||
}))
|
||||
}, [listQuery.data])
|
||||
|
||||
const openDetail = (id: string, name: string) => {
|
||||
setDetailDeviceId(id)
|
||||
setDetailDeviceName(name)
|
||||
}
|
||||
|
||||
const [connectingId, setConnectingId] = useState<string | null>(null)
|
||||
const iniciarSesionMutation = trpc.equipos.iniciarSesion.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setConnectError(null)
|
||||
setConnectingId(null)
|
||||
utils.sesiones.list.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
if (data.url) window.open(data.url, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
onError: (err) => {
|
||||
setConnectError(err.message)
|
||||
setConnectingId(null)
|
||||
},
|
||||
})
|
||||
|
||||
const handleConnect = (id: string) => {
|
||||
console.log('Conectar', id)
|
||||
setConnectError(null)
|
||||
setConnectingId(id)
|
||||
iniciarSesionMutation.mutate({ dispositivoId: id, tipo: 'desktop' })
|
||||
}
|
||||
const handleFiles = (id: string) => {
|
||||
console.log('Archivos', id)
|
||||
router.push(`/archivos?deviceId=${encodeURIComponent(id)}`)
|
||||
}
|
||||
const handleTerminal = (id: string) => {
|
||||
console.log('Terminal', id)
|
||||
router.push(`/terminal?deviceId=${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">Dispositivos</h1>
|
||||
<p className="mt-1 text-gray-400">Administración de equipos conectados</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="btn btn-primary inline-flex items-center gap-2 shrink-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar Dispositivo
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{connectError && (
|
||||
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-3 text-sm text-red-400 flex items-center justify-between gap-2">
|
||||
<span>{connectError}</span>
|
||||
<button type="button" onClick={() => setConnectError(null)} className="text-red-400 hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddDeviceModal
|
||||
open={addModalOpen}
|
||||
onClose={() => setAddModalOpen(false)}
|
||||
clienteId={clienteId}
|
||||
onSuccess={() => utils.equipos.list.invalidate()}
|
||||
/>
|
||||
|
||||
<DeviceDetailModal
|
||||
open={!!detailDeviceId}
|
||||
onClose={() => setDetailDeviceId(null)}
|
||||
deviceId={detailDeviceId}
|
||||
deviceName={detailDeviceName}
|
||||
onConnect={handleConnect}
|
||||
onTerminal={handleTerminal}
|
||||
onFiles={handleFiles}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||
@@ -166,6 +233,8 @@ export default function DevicesPage() {
|
||||
onConectar={handleConnect}
|
||||
onArchivos={handleFiles}
|
||||
onTerminal={handleTerminal}
|
||||
onInfo={(id, name) => openDetail(id, name ?? device.name)}
|
||||
isConnecting={connectingId === device.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
73
src/app/(dashboard)/headwind/page.tsx
Normal file
73
src/app/(dashboard)/headwind/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import HeadwindMetricCard from '@/components/headwind/HeadwindMetricCard'
|
||||
import MobileDeviceList from '@/components/headwind/MobileDeviceList'
|
||||
import CorporateAppsList from '@/components/headwind/CorporateAppsList'
|
||||
import {
|
||||
getMdmDashboardData,
|
||||
type Device,
|
||||
type AppDeployment,
|
||||
type DashboardStats,
|
||||
} from '@/mocks/mdmDashboardData'
|
||||
|
||||
export default function HeadwindPage() {
|
||||
useSelectedClient()
|
||||
|
||||
const initial = useMemo(() => getMdmDashboardData(), [])
|
||||
const [stats] = useState<DashboardStats>(initial.stats)
|
||||
const [devices] = useState<Device[]>(initial.devices)
|
||||
const [appDeployments] = useState<AppDeployment[]>(initial.appDeployments)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
Headwind MDM
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
Gestión de dispositivos móviles Android
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
|
||||
aria-label="Resumen"
|
||||
>
|
||||
<HeadwindMetricCard
|
||||
label="Dispositivos Android"
|
||||
value={stats.totalAndroidDevices}
|
||||
subtitle="Total registrados"
|
||||
accent="green"
|
||||
/>
|
||||
<HeadwindMetricCard
|
||||
label="Apps desplegadas"
|
||||
value={stats.deployedApps}
|
||||
subtitle="En catálogo"
|
||||
accent="blue"
|
||||
/>
|
||||
<HeadwindMetricCard
|
||||
label="Políticas activas"
|
||||
value={stats.activePolicies}
|
||||
subtitle="Configuraciones aplicadas"
|
||||
accent="cyan"
|
||||
/>
|
||||
<HeadwindMetricCard
|
||||
label="Batería promedio"
|
||||
value={`${stats.averageBatteryPercent}%`}
|
||||
subtitle="Estado actual"
|
||||
accent="amber"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
|
||||
aria-label="Dispositivos y apps"
|
||||
>
|
||||
<MobileDeviceList devices={devices} />
|
||||
<CorporateAppsList apps={appDeployments} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -94,6 +94,12 @@ function DashboardContentInner({
|
||||
)
|
||||
const devicesCount = devicesCountQuery.data?.pagination?.total ?? 0
|
||||
|
||||
const sessionsCountQuery = trpc.sesiones.count.useQuery(
|
||||
{ clienteId },
|
||||
{ refetchOnWindowFocus: true, staleTime: 15 * 1000 }
|
||||
)
|
||||
const sessionsCount = sessionsCountQuery.data ?? 0
|
||||
|
||||
const clientsQuery = trpc.clientes.list.useQuery(
|
||||
{ limit: 100 },
|
||||
{ staleTime: 60 * 1000 }
|
||||
@@ -111,6 +117,7 @@ function DashboardContentInner({
|
||||
<Sidebar
|
||||
activeAlertsCount={activeAlertsCount}
|
||||
devicesCount={devicesCount}
|
||||
sessionsCount={sessionsCount}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
105
src/app/(dashboard)/librenms/page.tsx
Normal file
105
src/app/(dashboard)/librenms/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import LibrenmsMetricCard from '@/components/librenms/LibrenmsMetricCard'
|
||||
import DeviceList from '@/components/librenms/DeviceList'
|
||||
import AlertList from '@/components/librenms/AlertList'
|
||||
import type { NetworkDevice } from '@/components/librenms/DeviceRow'
|
||||
import type { SnmpAlert } from '@/components/librenms/AlertItem'
|
||||
|
||||
const MOCK_DEVICES: NetworkDevice[] = [
|
||||
{ id: '1', name: 'Core Switch', model: 'Cisco 3850', status: 'online' },
|
||||
{ id: '2', name: 'Router Principal', model: 'MikroTik CCR', status: 'online' },
|
||||
{ id: '3', name: 'AP-Oficina-01', model: 'Ubiquiti UAP', status: 'online' },
|
||||
{ id: '4', name: 'Switch-Piso2', model: 'HP ProCurve', status: 'warning' },
|
||||
{ id: '5', name: 'Firewall', model: 'pfSense', status: 'online' },
|
||||
]
|
||||
|
||||
const MOCK_ALERTS: SnmpAlert[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'CPU Alto – Core Switch',
|
||||
detail: 'Hace 15 min – 92% utilización',
|
||||
severity: 'critical',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Puerto Down – Switch-Piso2',
|
||||
detail: 'Hace 1h – GigabitEthernet0/12',
|
||||
severity: 'warning',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Alto tráfico – WAN',
|
||||
detail: 'Hace 2h – 95% capacidad',
|
||||
severity: 'critical',
|
||||
},
|
||||
]
|
||||
|
||||
export default function LibrenmsPage() {
|
||||
useSelectedClient()
|
||||
|
||||
const [devices] = useState<NetworkDevice[]>(MOCK_DEVICES)
|
||||
const [alerts] = useState<SnmpAlert[]>(MOCK_ALERTS)
|
||||
const [metrics] = useState({
|
||||
totalDevices: 28,
|
||||
uptime: 99.7,
|
||||
activeAlerts: 3,
|
||||
trafficToday: 847,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
LibreNMS - Monitoreo de Red
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
SNMP, NetFlow y alertas de infraestructura
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* KPI metric cards */}
|
||||
<section
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
|
||||
aria-label="Métricas principales"
|
||||
>
|
||||
<LibrenmsMetricCard
|
||||
label="Dispositivos Red"
|
||||
value={metrics.totalDevices}
|
||||
subtitle="Switches, routers, APs"
|
||||
accent="green"
|
||||
/>
|
||||
<LibrenmsMetricCard
|
||||
label="Uptime Promedio"
|
||||
value={`${metrics.uptime}%`}
|
||||
subtitle="Últimos 30 días"
|
||||
accent="cyan"
|
||||
/>
|
||||
<LibrenmsMetricCard
|
||||
label="Alertas Activas"
|
||||
value={metrics.activeAlerts}
|
||||
subtitle="2 críticas, 1 warning"
|
||||
accent="yellow"
|
||||
/>
|
||||
<LibrenmsMetricCard
|
||||
label="Tráfico Total"
|
||||
value={`${metrics.trafficToday} GB`}
|
||||
subtitle="NetFlow hoy"
|
||||
accent="blue"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Main content grid */}
|
||||
<section
|
||||
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
|
||||
aria-label="Dispositivos y alertas"
|
||||
>
|
||||
<DeviceList devices={devices} />
|
||||
<AlertList alerts={alerts} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,178 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { RefreshCw, Grid, List, Filter } from 'lucide-react'
|
||||
import KPICards from '@/components/dashboard/KPICards'
|
||||
import DeviceGrid from '@/components/dashboard/DeviceGrid'
|
||||
import AlertsFeed from '@/components/dashboard/AlertsFeed'
|
||||
import { useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Monitor, CheckCircle, XCircle, AlertTriangle, Plus } from 'lucide-react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import SummaryCard from '@/components/dashboard/SummaryCard'
|
||||
import DeviceStatusChart from '@/components/dashboard/DeviceStatusChart'
|
||||
import RecentActivityList from '@/components/dashboard/RecentActivityList'
|
||||
import SystemHealthCard from '@/components/dashboard/SystemHealthCard'
|
||||
import QuickConnectionCard from '@/components/dashboard/QuickConnectionCard'
|
||||
import {
|
||||
MOCK_DASHBOARD_SECONDARY,
|
||||
MOCK_SYSTEM_HEALTH,
|
||||
MOCK_QUICK_CONNECTIONS,
|
||||
} from '@/mocks/dashboardData'
|
||||
import type {
|
||||
QuickConnectionItem,
|
||||
QuickConnectionStatus,
|
||||
RecentActivityItem,
|
||||
} from '@/mocks/dashboardData'
|
||||
|
||||
type DeviceForGrid = {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
estado: string
|
||||
ip?: string | null
|
||||
sistemaOperativo?: string | null
|
||||
lastSeen?: Date | null
|
||||
cpuUsage?: number | null
|
||||
ramUsage?: number | null
|
||||
cliente?: { nombre: string }
|
||||
}
|
||||
|
||||
type DashboardAlert = {
|
||||
id: string
|
||||
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
|
||||
estado: 'ACTIVA' | 'RECONOCIDA' | 'RESUELTA'
|
||||
titulo: string
|
||||
mensaje: string
|
||||
createdAt: Date
|
||||
dispositivo: { nombre: string }
|
||||
cliente: { nombre: string }
|
||||
}
|
||||
|
||||
// Mock data - en produccion vendria de la API
|
||||
const mockStats = {
|
||||
totalDispositivos: 127,
|
||||
dispositivosOnline: 98,
|
||||
dispositivosOffline: 24,
|
||||
dispositivosAlerta: 5,
|
||||
alertasActivas: 8,
|
||||
alertasCriticas: 2,
|
||||
sesionesActivas: 3,
|
||||
}
|
||||
|
||||
const mockDevices = [
|
||||
{
|
||||
id: '1',
|
||||
nombre: 'SRV-PRINCIPAL',
|
||||
tipo: 'SERVIDOR',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.10',
|
||||
sistemaOperativo: 'Windows Server 2022',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: 45,
|
||||
ramUsage: 72,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nombre: 'PC-ADMIN-01',
|
||||
tipo: 'PC',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.101',
|
||||
sistemaOperativo: 'Windows 11 Pro',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: 23,
|
||||
ramUsage: 56,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nombre: 'LAPTOP-VENTAS',
|
||||
tipo: 'LAPTOP',
|
||||
estado: 'ALERTA',
|
||||
ip: '192.168.1.105',
|
||||
sistemaOperativo: 'Windows 11 Pro',
|
||||
lastSeen: new Date(Date.now() - 1000 * 60 * 5),
|
||||
cpuUsage: 95,
|
||||
ramUsage: 88,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
nombre: 'ROUTER-PRINCIPAL',
|
||||
tipo: 'ROUTER',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.1',
|
||||
sistemaOperativo: 'RouterOS 7.12',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
nombre: 'SW-CORE-01',
|
||||
tipo: 'SWITCH',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.2',
|
||||
sistemaOperativo: 'Cisco IOS',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
nombre: 'CELULAR-GERENTE',
|
||||
tipo: 'CELULAR',
|
||||
estado: 'ONLINE',
|
||||
ip: null,
|
||||
sistemaOperativo: 'Android 14',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
nombre: 'SRV-BACKUP',
|
||||
tipo: 'SERVIDOR',
|
||||
estado: 'OFFLINE',
|
||||
ip: '192.168.1.11',
|
||||
sistemaOperativo: 'Ubuntu 22.04',
|
||||
lastSeen: new Date(Date.now() - 1000 * 60 * 60 * 2),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
nombre: 'AP-OFICINA-01',
|
||||
tipo: 'AP',
|
||||
estado: 'ONLINE',
|
||||
ip: '192.168.1.50',
|
||||
sistemaOperativo: 'UniFi AP',
|
||||
lastSeen: new Date(),
|
||||
cpuUsage: null,
|
||||
ramUsage: null,
|
||||
},
|
||||
]
|
||||
|
||||
const mockAlerts = [
|
||||
{
|
||||
id: '1',
|
||||
severidad: 'CRITICAL' as const,
|
||||
estado: 'ACTIVA' as const,
|
||||
titulo: 'Servidor de backup offline',
|
||||
mensaje: 'El servidor SRV-BACKUP no responde desde hace 2 horas',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 120),
|
||||
dispositivo: { nombre: 'SRV-BACKUP' },
|
||||
cliente: { nombre: 'Cliente A' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
severidad: 'WARNING' as const,
|
||||
estado: 'ACTIVA' as const,
|
||||
titulo: 'CPU alta',
|
||||
mensaje: 'Uso de CPU al 95% en LAPTOP-VENTAS',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 15),
|
||||
dispositivo: { nombre: 'LAPTOP-VENTAS' },
|
||||
cliente: { nombre: 'Cliente A' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
severidad: 'INFO' as const,
|
||||
estado: 'RECONOCIDA' as const,
|
||||
titulo: 'Actualizacion disponible',
|
||||
mensaje: 'Windows Update pendiente en PC-ADMIN-01',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60),
|
||||
dispositivo: { nombre: 'PC-ADMIN-01' },
|
||||
cliente: { nombre: 'Cliente A' },
|
||||
},
|
||||
]
|
||||
|
||||
const DEVICES_LIMIT = 12
|
||||
const DEVICES_LIMIT = 8
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const utils = trpc.useUtils()
|
||||
const { selectedClientId } = useSelectedClient()
|
||||
const clienteId = selectedClientId ?? undefined
|
||||
|
||||
@@ -180,38 +31,6 @@ export default function DashboardPage() {
|
||||
{ clienteId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
const stats = statsQuery.data ?? mockStats
|
||||
|
||||
const alertsQuery = trpc.alertas.list.useQuery(
|
||||
{ page: 1, limit: 25, clienteId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
const alerts: DashboardAlert[] = useMemo(() => {
|
||||
const list = alertsQuery.data?.alertas ?? []
|
||||
return list.map((a) => ({
|
||||
id: a.id,
|
||||
severidad: a.severidad,
|
||||
estado: a.estado,
|
||||
titulo: a.titulo,
|
||||
mensaje: a.mensaje,
|
||||
createdAt: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
|
||||
dispositivo: a.dispositivo ? { nombre: a.dispositivo.nombre } : { nombre: '—' },
|
||||
cliente: { nombre: a.cliente.nombre },
|
||||
}))
|
||||
}, [alertsQuery.data])
|
||||
|
||||
const acknowledgeMutation = trpc.alertas.reconocer.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.alertas.list.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
},
|
||||
})
|
||||
const resolveMutation = trpc.alertas.resolver.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.alertas.list.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const equiposQuery = trpc.equipos.list.useQuery(
|
||||
{ page: 1, limit: DEVICES_LIMIT, clienteId },
|
||||
@@ -226,147 +45,155 @@ export default function DashboardPage() {
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
const devices: DeviceForGrid[] = useMemo(() => {
|
||||
const eq = equiposQuery.data?.dispositivos ?? []
|
||||
const rd = redQuery.data?.dispositivos ?? []
|
||||
const cel = celularesQuery.data?.dispositivos ?? []
|
||||
const all = [...eq, ...rd, ...cel]
|
||||
return all.map((d) => ({
|
||||
id: d.id,
|
||||
nombre: d.nombre,
|
||||
tipo: d.tipo,
|
||||
estado: d.estado,
|
||||
ip: d.ip ?? null,
|
||||
sistemaOperativo: d.sistemaOperativo ?? null,
|
||||
lastSeen: d.lastSeen ?? null,
|
||||
cpuUsage: d.cpuUsage ?? null,
|
||||
ramUsage: d.ramUsage ?? null,
|
||||
cliente: d.cliente ? { nombre: d.cliente.nombre } : undefined,
|
||||
const alertsQuery = trpc.alertas.list.useQuery(
|
||||
{ page: 1, limit: 15, clienteId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
const recentActivity: RecentActivityItem[] = useMemo(() => {
|
||||
const list = alertsQuery.data?.alertas ?? []
|
||||
return list.map((a) => ({
|
||||
id: a.id,
|
||||
type: 'alert' as const,
|
||||
description: a.titulo,
|
||||
deviceName: a.dispositivo?.nombre ?? '—',
|
||||
timestamp: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
|
||||
severity: a.severidad,
|
||||
}))
|
||||
}, [equiposQuery.data, redQuery.data, celularesQuery.data])
|
||||
}, [alertsQuery.data])
|
||||
|
||||
const devicesLoading =
|
||||
equiposQuery.isLoading || redQuery.isLoading || celularesQuery.isLoading
|
||||
const isRefreshing =
|
||||
statsQuery.isFetching ||
|
||||
alertsQuery.isFetching ||
|
||||
equiposQuery.isFetching ||
|
||||
redQuery.isFetching ||
|
||||
celularesQuery.isFetching
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([
|
||||
statsQuery.refetch(),
|
||||
alertsQuery.refetch(),
|
||||
equiposQuery.refetch(),
|
||||
redQuery.refetch(),
|
||||
celularesQuery.refetch(),
|
||||
])
|
||||
const stats = statsQuery.data ?? {
|
||||
totalDispositivos: 0,
|
||||
dispositivosOnline: 0,
|
||||
dispositivosOffline: 0,
|
||||
dispositivosAlerta: 0,
|
||||
alertasActivas: 0,
|
||||
alertasCriticas: 0,
|
||||
sesionesActivas: 0,
|
||||
}
|
||||
|
||||
const handleDeviceAction = (deviceId: string, action: string) => {
|
||||
console.log(`Action ${action} on device ${deviceId}`)
|
||||
// TODO: Implementar acciones
|
||||
const deviceStatusBreakdown = {
|
||||
online: stats.dispositivosOnline,
|
||||
offline: stats.dispositivosOffline,
|
||||
advertencia: stats.dispositivosAlerta,
|
||||
}
|
||||
|
||||
const handleAcknowledgeAlert = (alertId: string) => {
|
||||
acknowledgeMutation.mutate({ id: alertId })
|
||||
}
|
||||
const allDevices = [
|
||||
...(equiposQuery.data?.dispositivos ?? []),
|
||||
...(redQuery.data?.dispositivos ?? []),
|
||||
...(celularesQuery.data?.dispositivos ?? []),
|
||||
].slice(0, DEVICES_LIMIT)
|
||||
|
||||
const handleResolveAlert = (alertId: string) => {
|
||||
resolveMutation.mutate({ id: alertId })
|
||||
}
|
||||
const quickConnections: QuickConnectionItem[] =
|
||||
allDevices.length > 0
|
||||
? allDevices.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.nombre,
|
||||
status: mapEstadoToQuickStatus(d.estado),
|
||||
}))
|
||||
: MOCK_QUICK_CONNECTIONS
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-gray-500">Vision general del sistema</p>
|
||||
<h1 className="text-3xl font-bold text-white">MSP-CAS Dashboard</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
MeshCentral + LibreNMS + Headwind MDM unificados
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="btn btn-secondary"
|
||||
disabled={isRefreshing}
|
||||
<div className="flex shrink-0">
|
||||
<Link
|
||||
href="/devices"
|
||||
className="btn btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isRefreshing && 'animate-spin')} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar Dispositivo
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<KPICards stats={stats} />
|
||||
<section
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
aria-label="Resumen"
|
||||
>
|
||||
<SummaryCard
|
||||
title="Total Dispositivos"
|
||||
value={stats.totalDispositivos}
|
||||
secondary={MOCK_DASHBOARD_SECONDARY.total}
|
||||
icon={<Monitor className="w-6 h-6" />}
|
||||
iconBgClass="bg-primary-900/30"
|
||||
iconColorClass="text-primary-400"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="En Línea"
|
||||
value={stats.dispositivosOnline}
|
||||
secondary={
|
||||
stats.totalDispositivos > 0
|
||||
? `${Math.round((stats.dispositivosOnline / stats.totalDispositivos) * 100)}% disponibilidad`
|
||||
: MOCK_DASHBOARD_SECONDARY.online
|
||||
}
|
||||
icon={<CheckCircle className="w-6 h-6" />}
|
||||
iconBgClass="bg-success/20"
|
||||
iconColorClass="text-success"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Fuera de Línea"
|
||||
value={stats.dispositivosOffline}
|
||||
secondary={MOCK_DASHBOARD_SECONDARY.offline}
|
||||
icon={<XCircle className="w-6 h-6" />}
|
||||
iconBgClass="bg-gray-500/20"
|
||||
iconColorClass="text-gray-400"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Advertencias"
|
||||
value={stats.dispositivosAlerta}
|
||||
secondary={MOCK_DASHBOARD_SECONDARY.alerta}
|
||||
icon={<AlertTriangle className="w-6 h-6" />}
|
||||
iconBgClass="bg-warning/20"
|
||||
iconColorClass="text-warning"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Devices */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Dispositivos</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn btn-ghost btn-sm">
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
Filtrar
|
||||
</button>
|
||||
<div className="flex border border-dark-100 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={cn(
|
||||
'p-2 transition-colors',
|
||||
viewMode === 'grid' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={cn(
|
||||
'p-2 transition-colors',
|
||||
viewMode === 'list' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="lg:col-span-2">
|
||||
<DeviceStatusChart data={deviceStatusBreakdown} />
|
||||
</section>
|
||||
<section className="min-h-0">
|
||||
<RecentActivityList items={recentActivity} isLoading={alertsQuery.isLoading} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{devicesLoading ? (
|
||||
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400">
|
||||
Cargando dispositivos...
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400">
|
||||
No hay dispositivos. Agregue clientes y sincronice con MeshCentral, LibreNMS o Headwind.
|
||||
</div>
|
||||
) : (
|
||||
<DeviceGrid
|
||||
devices={devices}
|
||||
viewMode={viewMode}
|
||||
onAction={handleDeviceAction}
|
||||
/>
|
||||
)}
|
||||
<section>
|
||||
<h2 className="text-lg font-medium text-gray-200 mb-4">Salud del Sistema</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.cpu} />
|
||||
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.ram} />
|
||||
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.network} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Alerts */}
|
||||
<div>
|
||||
{alertsQuery.isLoading ? (
|
||||
<div className="card p-8 text-center text-gray-400">
|
||||
Cargando alertas...
|
||||
</div>
|
||||
) : (
|
||||
<AlertsFeed
|
||||
alerts={alerts}
|
||||
onAcknowledge={handleAcknowledgeAlert}
|
||||
onResolve={handleResolveAlert}
|
||||
maxItems={10}
|
||||
<section>
|
||||
<h2 className="text-lg font-medium text-gray-200 mb-4">Conexión Rápida</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{quickConnections.map((item) => (
|
||||
<QuickConnectionCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={(id) => {
|
||||
// TODO: router.push(`/devices?id=${id}`) or open device detail modal
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function mapEstadoToQuickStatus(estado: string): QuickConnectionStatus {
|
||||
const u = estado?.toUpperCase()
|
||||
if (u === 'ONLINE') return 'online'
|
||||
if (u === 'ALERTA') return 'advertencia'
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
124
src/app/(dashboard)/terminal/page.tsx
Normal file
124
src/app/(dashboard)/terminal/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
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 searchParams = useSearchParams()
|
||||
const deviceIdFromUrl = searchParams.get('deviceId')
|
||||
|
||||
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>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceIdFromUrl || !listQuery.data?.dispositivos?.length) return
|
||||
const exists = listQuery.data.dispositivos.some((d) => d.id === deviceIdFromUrl)
|
||||
if (exists) setSelectedDeviceId(deviceIdFromUrl)
|
||||
}, [deviceIdFromUrl, listQuery.data?.dispositivos])
|
||||
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>
|
||||
)
|
||||
}
|
||||
88
src/components/dashboard/DeviceStatusChart.tsx
Normal file
88
src/components/dashboard/DeviceStatusChart.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
|
||||
import type { DeviceStatusBreakdown } from '@/mocks/dashboardData'
|
||||
|
||||
interface DeviceStatusChartProps {
|
||||
data: DeviceStatusBreakdown
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
online: '#22c55e',
|
||||
offline: '#64748b',
|
||||
advertencia: '#eab308',
|
||||
}
|
||||
|
||||
export default function DeviceStatusChart({ data }: DeviceStatusChartProps) {
|
||||
const total = data.online + data.offline + data.advertencia
|
||||
const segments = [
|
||||
{ name: 'En Línea', value: data.online, color: COLORS.online },
|
||||
{ name: 'Fuera de Línea', value: data.offline, color: COLORS.offline },
|
||||
{ name: 'Advertencia', value: data.advertencia, color: COLORS.advertencia },
|
||||
].filter((s) => s.value > 0)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<h3 className="font-medium text-gray-200 mb-4">Estado de Dispositivos</h3>
|
||||
<div className="flex h-48 items-center justify-center text-gray-500 text-sm">
|
||||
Sin datos
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<h3 className="font-medium text-gray-200 mb-4">Estado de Dispositivos</h3>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="w-full max-w-[200px] h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={segments}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={56}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{segments.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.color} stroke="transparent" />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`${value} (${total > 0 ? Math.round((value / total) * 100) : 0}%)`,
|
||||
'',
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2 w-full sm:w-auto">
|
||||
{segments.map((s) => (
|
||||
<li key={s.name} className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="text-gray-400 tabular-nums">
|
||||
{s.value} ({total > 0 ? Math.round((s.value / total) * 100) : 0}%)
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/dashboard/QuickConnectionCard.tsx
Normal file
38
src/components/dashboard/QuickConnectionCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QuickConnectionItem, QuickConnectionStatus } from '@/mocks/dashboardData'
|
||||
|
||||
interface QuickConnectionCardProps {
|
||||
item: QuickConnectionItem
|
||||
onClick?: (id: string) => void
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
QuickConnectionStatus,
|
||||
{ dot: string; label: string; text: string }
|
||||
> = {
|
||||
online: { dot: 'bg-success', label: 'En línea', text: 'text-success' },
|
||||
advertencia: { dot: 'bg-warning', label: 'Advertencia', text: 'text-warning' },
|
||||
offline: { dot: 'bg-gray-500', label: 'Fuera de línea', text: 'text-gray-500' },
|
||||
}
|
||||
|
||||
export default function QuickConnectionCard({ item, onClick }: QuickConnectionCardProps) {
|
||||
const config = statusConfig[item.status]
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(item.id)}
|
||||
className={cn(
|
||||
'card p-4 text-left transition-all hover:scale-[1.02] hover:border-primary-500/50',
|
||||
'flex items-center justify-between gap-3'
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-gray-200 truncate">{item.name}</span>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm shrink-0', config.text)}>
|
||||
<span className={cn('h-2 w-2 rounded-full', config.dot)} />
|
||||
{config.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
92
src/components/dashboard/RecentActivityList.tsx
Normal file
92
src/components/dashboard/RecentActivityList.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
LogIn,
|
||||
AlertTriangle,
|
||||
Link2,
|
||||
Link2Off,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
import type { RecentActivityItem, RecentActivityType } from '@/mocks/dashboardData'
|
||||
import type { AlertSeverity } from '@/mocks/dashboardData'
|
||||
|
||||
function formatRelative(date: Date): string {
|
||||
const s = formatRelativeTime(date)
|
||||
return s === 'ahora' ? 'Hace un momento' : s.replace(/^hace/, 'Hace')
|
||||
}
|
||||
|
||||
interface RecentActivityListProps {
|
||||
items: RecentActivityItem[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const severityIconBg: Record<AlertSeverity, string> = {
|
||||
CRITICAL: 'bg-danger/20 text-danger',
|
||||
WARNING: 'bg-warning/20 text-warning',
|
||||
INFO: 'bg-info/20 text-info',
|
||||
}
|
||||
|
||||
const typeConfig: Record<
|
||||
RecentActivityType,
|
||||
{ icon: React.ReactNode; label: string }
|
||||
> = {
|
||||
login: { icon: <LogIn className="w-4 h-4" />, label: 'Login' },
|
||||
alert: { icon: <AlertTriangle className="w-4 h-4" />, label: 'Alerta' },
|
||||
connection: { icon: <Link2 className="w-4 h-4" />, label: 'Conexión' },
|
||||
disconnection: { icon: <Link2Off className="w-4 h-4" />, label: 'Desconexión' },
|
||||
}
|
||||
|
||||
export default function RecentActivityList({ items, isLoading }: RecentActivityListProps) {
|
||||
return (
|
||||
<div className="card overflow-hidden flex flex-col h-full min-h-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-200">Actividad Reciente</h3>
|
||||
<Link href="/alerts" className="text-sm text-primary-500 hover:underline">
|
||||
Ver todas
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 divide-y divide-dark-100 max-h-[320px]">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
Cargando...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
No hay alertas recientes
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const config = typeConfig[item.type]
|
||||
const iconBg =
|
||||
item.type === 'alert' && item.severity
|
||||
? severityIconBg[item.severity]
|
||||
: 'bg-dark-300 text-gray-400'
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 p-4 hover:bg-dark-300/30 transition-colors"
|
||||
>
|
||||
<div className={cn('p-2 rounded-lg shrink-0', iconBg)}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-gray-200">
|
||||
{item.description}
|
||||
{' · '}
|
||||
<span className="font-medium text-primary-400">
|
||||
{item.deviceName}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{formatRelative(item.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/dashboard/SummaryCard.tsx
Normal file
38
src/components/dashboard/SummaryCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string
|
||||
value: number
|
||||
secondary?: string
|
||||
icon: React.ReactNode
|
||||
iconBgClass?: string
|
||||
iconColorClass?: string
|
||||
}
|
||||
|
||||
export default function SummaryCard({
|
||||
title,
|
||||
value,
|
||||
secondary,
|
||||
icon,
|
||||
iconBgClass = 'bg-dark-300',
|
||||
iconColorClass = 'text-primary-400',
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<div className="card p-4 transition-all hover:scale-[1.02]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{title}</p>
|
||||
<p className="text-3xl font-bold mt-1">{value}</p>
|
||||
{secondary && (
|
||||
<p className="text-xs text-gray-500 mt-1">{secondary}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('p-3 rounded-lg', iconBgClass)}>
|
||||
<span className={iconColorClass}>{icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/components/dashboard/SystemHealthCard.tsx
Normal file
37
src/components/dashboard/SystemHealthCard.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SystemHealthMetric } from '@/mocks/dashboardData'
|
||||
|
||||
interface SystemHealthCardProps {
|
||||
metric: SystemHealthMetric
|
||||
}
|
||||
|
||||
const statusBarClass = {
|
||||
healthy: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
critical: 'bg-danger',
|
||||
}
|
||||
|
||||
export default function SystemHealthCard({ metric }: SystemHealthCardProps) {
|
||||
const percent = metric.unit === '%' ? metric.value : Math.min(100, (metric.value / 200) * 100)
|
||||
const barClass = statusBarClass[metric.status]
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">{metric.label}</span>
|
||||
<span className="text-lg font-semibold tabular-nums text-gray-200">
|
||||
{metric.value}
|
||||
{metric.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-dark-300 overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', barClass)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
373
src/components/devices/AddDeviceModal.tsx
Normal file
373
src/components/devices/AddDeviceModal.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TIPO_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'PC', label: 'PC' },
|
||||
{ value: 'LAPTOP', label: 'Laptop' },
|
||||
{ value: 'SERVIDOR', label: 'Servidor' },
|
||||
{ value: 'CELULAR', label: 'Celular' },
|
||||
{ value: 'TABLET', label: 'Tablet' },
|
||||
{ value: 'ROUTER', label: 'Router' },
|
||||
{ value: 'SWITCH', label: 'Switch' },
|
||||
{ value: 'FIREWALL', label: 'Firewall' },
|
||||
{ value: 'AP', label: 'Access Point' },
|
||||
{ value: 'IMPRESORA', label: 'Impresora' },
|
||||
{ value: 'OTRO', label: 'Otro' },
|
||||
]
|
||||
|
||||
const ESTADO_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'DESCONOCIDO', label: 'Desconocido' },
|
||||
{ value: 'ONLINE', label: 'En línea' },
|
||||
{ value: 'OFFLINE', label: 'Fuera de línea' },
|
||||
{ value: 'ALERTA', label: 'Advertencia' },
|
||||
{ value: 'MANTENIMIENTO', label: 'Mantenimiento' },
|
||||
]
|
||||
|
||||
export interface AddDeviceFormValues {
|
||||
tipo: string
|
||||
nombre: string
|
||||
descripcion: string
|
||||
ubicacionId: string
|
||||
estado: string
|
||||
ip: string
|
||||
mac: string
|
||||
sistemaOperativo: string
|
||||
versionSO: string
|
||||
fabricante: string
|
||||
modelo: string
|
||||
serial: string
|
||||
}
|
||||
|
||||
const initialValues: AddDeviceFormValues = {
|
||||
tipo: 'PC',
|
||||
nombre: '',
|
||||
descripcion: '',
|
||||
ubicacionId: '',
|
||||
estado: 'DESCONOCIDO',
|
||||
ip: '',
|
||||
mac: '',
|
||||
sistemaOperativo: '',
|
||||
versionSO: '',
|
||||
fabricante: '',
|
||||
modelo: '',
|
||||
serial: '',
|
||||
}
|
||||
|
||||
interface AddDeviceModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
clienteId: string | undefined
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 placeholder-gray-500 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20'
|
||||
|
||||
export default function AddDeviceModal({
|
||||
open,
|
||||
onClose,
|
||||
clienteId,
|
||||
onSuccess,
|
||||
}: AddDeviceModalProps) {
|
||||
const [form, setForm] = useState<AddDeviceFormValues>(initialValues)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const ubicacionesQuery = trpc.clientes.ubicaciones.list.useQuery(
|
||||
{ clienteId: clienteId! },
|
||||
{ enabled: open && !!clienteId }
|
||||
)
|
||||
const createMutation = trpc.equipos.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.equipos.list.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
onSuccess?.()
|
||||
handleClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
setSubmitError(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setForm(initialValues)
|
||||
setSubmitError(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleClose = () => {
|
||||
setSubmitError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitError(null)
|
||||
if (!clienteId) {
|
||||
setSubmitError('Seleccione un cliente.')
|
||||
return
|
||||
}
|
||||
createMutation.mutate({
|
||||
clienteId,
|
||||
tipo: form.tipo as AddDeviceFormValues['tipo'],
|
||||
nombre: form.nombre.trim(),
|
||||
descripcion: form.descripcion.trim() || undefined,
|
||||
ubicacionId: form.ubicacionId || undefined,
|
||||
estado: form.estado as AddDeviceFormValues['estado'],
|
||||
ip: form.ip.trim() || undefined,
|
||||
mac: form.mac.trim() || undefined,
|
||||
sistemaOperativo: form.sistemaOperativo.trim() || undefined,
|
||||
versionSO: form.versionSO.trim() || undefined,
|
||||
fabricante: form.fabricante.trim() || undefined,
|
||||
modelo: form.modelo.trim() || undefined,
|
||||
serial: form.serial.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-device-title"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-xl border border-white/10 bg-dark-400 shadow-xl',
|
||||
'flex flex-col'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||
<h2 id="add-device-title" className="text-lg font-semibold text-white">
|
||||
Agregar Dispositivo
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 p-4 gap-4">
|
||||
{submitError && (
|
||||
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-2 text-sm text-red-400">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!clienteId && (
|
||||
<p className="text-sm text-amber-400">
|
||||
Seleccione un cliente en el selector del header para poder agregar dispositivos.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Tipo *
|
||||
</label>
|
||||
<select
|
||||
value={form.tipo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tipo: e.target.value }))}
|
||||
className={inputClass}
|
||||
required
|
||||
>
|
||||
{TIPO_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Nombre *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Ej: PC-Oficina-01"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={form.descripcion}
|
||||
onChange={(e) => setForm((f) => ({ ...f, descripcion: e.target.value }))}
|
||||
className={cn(inputClass, 'min-h-[80px] resize-y')}
|
||||
placeholder="Opcional"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Ubicación
|
||||
</label>
|
||||
<select
|
||||
value={form.ubicacionId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ubicacionId: e.target.value }))}
|
||||
className={inputClass}
|
||||
disabled={!clienteId}
|
||||
>
|
||||
<option value="">Sin ubicación</option>
|
||||
{(ubicacionesQuery.data ?? []).map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Estado inicial
|
||||
</label>
|
||||
<select
|
||||
value={form.estado}
|
||||
onChange={(e) => setForm((f) => ({ ...f, estado: e.target.value }))}
|
||||
className={inputClass}
|
||||
>
|
||||
{ESTADO_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
IP
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.ip}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ip: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="192.168.1.10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
MAC
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.mac}
|
||||
onChange={(e) => setForm((f) => ({ ...f, mac: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Sistema operativo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.sistemaOperativo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, sistemaOperativo: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Windows 11, Ubuntu, etc."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Versión SO
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.versionSO}
|
||||
onChange={(e) => setForm((f) => ({ ...f, versionSO: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Fabricante
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.fabricante}
|
||||
onChange={(e) => setForm((f) => ({ ...f, fabricante: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Dell, HP, Cisco..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Modelo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.modelo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, modelo: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Número de serie
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.serial}
|
||||
onChange={(e) => setForm((f) => ({ ...f, serial: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!clienteId || createMutation.isPending}
|
||||
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createMutation.isPending ? 'Creando...' : 'Crear dispositivo'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Monitor, FolderOpen, Terminal, Info, ExternalLink } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -17,6 +16,8 @@ export interface DeviceCardProps {
|
||||
onConectar?: (id: string) => void
|
||||
onArchivos?: (id: string) => void
|
||||
onTerminal?: (id: string) => void
|
||||
onInfo?: (id: string, name?: string) => void
|
||||
isConnecting?: boolean
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
@@ -46,16 +47,23 @@ export default function DeviceCard({
|
||||
onConectar,
|
||||
onArchivos,
|
||||
onTerminal,
|
||||
onInfo,
|
||||
isConnecting = false,
|
||||
}: DeviceCardProps) {
|
||||
const statusStyle = statusConfig[status]
|
||||
const osLabel = normalizeOS(os)
|
||||
const detailUrl = id ? `/devices/${id}` : '#'
|
||||
const handleCardClick = () => id && onInfo?.(id, name)
|
||||
|
||||
return (
|
||||
<div
|
||||
role={id && onInfo ? 'button' : undefined}
|
||||
tabIndex={id && onInfo ? 0 : undefined}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => id && onInfo && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onInfo(id, name))}
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5',
|
||||
'transition-all duration-200 hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20'
|
||||
'transition-all duration-200 hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20',
|
||||
id && onInfo && 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -89,14 +97,21 @@ export default function DeviceCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-4 gap-2">
|
||||
<div className="mt-4 grid grid-cols-4 gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => id && onConectar?.(id)}
|
||||
className="flex flex-col items-center gap-1 rounded-lg bg-dark-200/80 py-2.5 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400 border border-white/5"
|
||||
onClick={() => id && status === 'online' && !isConnecting && onConectar?.(id)}
|
||||
disabled={status !== 'online' || isConnecting}
|
||||
title={status !== 'online' ? 'Solo disponible para dispositivos en línea' : 'Conectar escritorio remoto'}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 rounded-lg py-2.5 border border-white/5',
|
||||
status === 'online' && !isConnecting
|
||||
? 'bg-dark-200/80 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400'
|
||||
: 'bg-dark-200/50 text-gray-600 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Conectar</span>
|
||||
<ExternalLink className={cn('h-4 w-4', isConnecting && 'animate-pulse')} />
|
||||
<span className="text-xs font-medium">{isConnecting ? 'Conectando…' : 'Conectar'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -114,13 +129,14 @@ export default function DeviceCard({
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Terminal</span>
|
||||
</button>
|
||||
<Link
|
||||
href={detailUrl}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => id && onInfo?.(id, name)}
|
||||
className="flex flex-col items-center gap-1 rounded-lg bg-dark-200/80 py-2.5 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400 border border-white/5"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Info</span>
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
52
src/components/devices/device-detail/ActionBar.tsx
Normal file
52
src/components/devices/device-detail/ActionBar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
// TODO: wire actions to MeshCentral remote session (equipos.iniciarSesion) and /terminal, /archivos routes
|
||||
import { ExternalLink, Terminal, FolderOpen } from 'lucide-react'
|
||||
|
||||
interface ActionBarProps {
|
||||
deviceId: string
|
||||
onConnect?: (id: string) => void
|
||||
onTerminal?: (id: string) => void
|
||||
onFiles?: (id: string) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function ActionBar({
|
||||
deviceId,
|
||||
onConnect,
|
||||
onTerminal,
|
||||
onFiles,
|
||||
loading = false,
|
||||
}: ActionBarProps) {
|
||||
return (
|
||||
<div className="sticky bottom-0 left-0 right-0 flex flex-wrap items-center gap-2 border-t border-white/10 bg-dark-400/95 backdrop-blur px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConnect?.(deviceId)}
|
||||
disabled={loading}
|
||||
className="btn btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{loading ? 'Conectando...' : 'Conectar Escritorio'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTerminal?.(deviceId)}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary inline-flex items-center gap-2"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFiles?.(deviceId)}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary inline-flex items-center gap-2"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Archivos
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
478
src/components/devices/device-detail/DeviceDetailModal.tsx
Normal file
478
src/components/devices/device-detail/DeviceDetailModal.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Monitor,
|
||||
Laptop,
|
||||
Server,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Router,
|
||||
Network,
|
||||
Shield,
|
||||
Wifi,
|
||||
Printer,
|
||||
HelpCircle,
|
||||
X,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { mapDeviceToDetail } from '@/mocks/deviceDetailData'
|
||||
import InfoSection from './InfoSection'
|
||||
import SoftwareList from './SoftwareList'
|
||||
import ActionBar from './ActionBar'
|
||||
|
||||
const DEVICE_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
PC: <Monitor className="h-6 w-6" />,
|
||||
LAPTOP: <Laptop className="h-6 w-6" />,
|
||||
SERVIDOR: <Server className="h-6 w-6" />,
|
||||
CELULAR: <Smartphone className="h-6 w-6" />,
|
||||
TABLET: <Tablet className="h-6 w-6" />,
|
||||
ROUTER: <Router className="h-6 w-6" />,
|
||||
SWITCH: <Network className="h-6 w-6" />,
|
||||
FIREWALL: <Shield className="h-6 w-6" />,
|
||||
AP: <Wifi className="h-6 w-6" />,
|
||||
IMPRESORA: <Printer className="h-6 w-6" />,
|
||||
OTRO: <HelpCircle className="h-6 w-6" />,
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'PC', label: 'PC' },
|
||||
{ value: 'LAPTOP', label: 'Laptop' },
|
||||
{ value: 'SERVIDOR', label: 'Servidor' },
|
||||
{ value: 'CELULAR', label: 'Celular' },
|
||||
{ value: 'TABLET', label: 'Tablet' },
|
||||
{ value: 'ROUTER', label: 'Router' },
|
||||
{ value: 'SWITCH', label: 'Switch' },
|
||||
{ value: 'FIREWALL', label: 'Firewall' },
|
||||
{ value: 'AP', label: 'Access Point' },
|
||||
{ value: 'IMPRESORA', label: 'Impresora' },
|
||||
{ value: 'OTRO', label: 'Otro' },
|
||||
]
|
||||
|
||||
const DEVICE_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'DESCONOCIDO', label: 'Desconocido' },
|
||||
{ value: 'ONLINE', label: 'En línea' },
|
||||
{ value: 'OFFLINE', label: 'Fuera de línea' },
|
||||
{ value: 'ALERTA', label: 'Advertencia' },
|
||||
{ value: 'MANTENIMIENTO', label: 'Mantenimiento' },
|
||||
]
|
||||
|
||||
const INPUT_CLASS =
|
||||
'w-full rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 placeholder-gray-500 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20'
|
||||
|
||||
type DeviceTypeValue =
|
||||
| 'PC'
|
||||
| 'LAPTOP'
|
||||
| 'SERVIDOR'
|
||||
| 'CELULAR'
|
||||
| 'TABLET'
|
||||
| 'ROUTER'
|
||||
| 'SWITCH'
|
||||
| 'FIREWALL'
|
||||
| 'AP'
|
||||
| 'IMPRESORA'
|
||||
| 'OTRO'
|
||||
|
||||
type DeviceStatusValue = 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO'
|
||||
|
||||
interface EditFormState {
|
||||
tipo: string
|
||||
nombre: string
|
||||
descripcion: string
|
||||
ubicacionId: string
|
||||
estado: string
|
||||
ip: string
|
||||
mac: string
|
||||
sistemaOperativo: string
|
||||
versionSO: string
|
||||
fabricante: string
|
||||
modelo: string
|
||||
serial: string
|
||||
}
|
||||
|
||||
interface DeviceFromApi {
|
||||
tipo: string
|
||||
nombre: string
|
||||
descripcion?: string | null
|
||||
ubicacionId?: string | null
|
||||
estado: string
|
||||
ip?: string | null
|
||||
mac?: string | null
|
||||
sistemaOperativo?: string | null
|
||||
versionSO?: string | null
|
||||
fabricante?: string | null
|
||||
modelo?: string | null
|
||||
serial?: string | null
|
||||
}
|
||||
|
||||
function buildEditFormFromDevice(device: DeviceFromApi): EditFormState {
|
||||
return {
|
||||
tipo: device.tipo,
|
||||
nombre: device.nombre,
|
||||
descripcion: device.descripcion ?? '',
|
||||
ubicacionId: device.ubicacionId ?? '',
|
||||
estado: device.estado,
|
||||
ip: device.ip ?? '',
|
||||
mac: device.mac ?? '',
|
||||
sistemaOperativo: device.sistemaOperativo ?? '',
|
||||
versionSO: device.versionSO ?? '',
|
||||
fabricante: device.fabricante ?? '',
|
||||
modelo: device.modelo ?? '',
|
||||
serial: device.serial ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
interface DeviceDetailModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
deviceId: string | null
|
||||
deviceName?: string
|
||||
onConnect?: (id: string) => void
|
||||
onTerminal?: (id: string) => void
|
||||
onFiles?: (id: string) => void
|
||||
}
|
||||
|
||||
export default function DeviceDetailModal({
|
||||
open,
|
||||
onClose,
|
||||
deviceId,
|
||||
deviceName = 'Dispositivo',
|
||||
onConnect,
|
||||
onTerminal,
|
||||
onFiles,
|
||||
}: DeviceDetailModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editForm, setEditForm] = useState<EditFormState | null>(null)
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const deviceQuery = trpc.equipos.byId.useQuery(
|
||||
{ id: deviceId! },
|
||||
{ enabled: open && !!deviceId }
|
||||
)
|
||||
const device = deviceQuery.data
|
||||
const clientId = device?.clienteId
|
||||
|
||||
const locationsQuery = trpc.clientes.ubicaciones.list.useQuery(
|
||||
{ clienteId: clientId! },
|
||||
{ enabled: open && !!clientId && isEditing }
|
||||
)
|
||||
|
||||
const updateMutation = trpc.equipos.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.equipos.byId.invalidate({ id: deviceId! })
|
||||
utils.equipos.list.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
setIsEditing(false)
|
||||
setEditForm(null)
|
||||
setEditError(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
setEditError(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIsEditing(false)
|
||||
setEditForm(null)
|
||||
setEditError(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && device) {
|
||||
setEditForm(buildEditFormFromDevice(device))
|
||||
setEditError(null)
|
||||
}
|
||||
}, [isEditing, device])
|
||||
|
||||
const detail = device ? mapDeviceToDetail(device) : null
|
||||
const isLoading = deviceQuery.isLoading
|
||||
const hasError = deviceQuery.isError
|
||||
|
||||
const systemItems = detail
|
||||
? [
|
||||
{ label: 'Sistema Operativo', value: detail.systemInfo.sistemaOperativo },
|
||||
{ label: 'Procesador', value: detail.systemInfo.procesador },
|
||||
{ label: 'Memoria RAM', value: detail.systemInfo.memoriaRam },
|
||||
{
|
||||
label: 'Almacenamiento',
|
||||
value:
|
||||
detail.systemInfo.almacenamientoUsoPercent != null
|
||||
? `${detail.systemInfo.almacenamiento} (${detail.systemInfo.almacenamientoUsoPercent}% uso)`
|
||||
: detail.systemInfo.almacenamiento,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const networkItems = detail
|
||||
? [
|
||||
{ label: 'Dirección IP', value: detail.networkInfo.direccionIp },
|
||||
{ label: 'Dirección MAC', value: detail.networkInfo.direccionMac },
|
||||
{ label: 'Gateway', value: detail.networkInfo.gateway },
|
||||
{ label: 'DNS', value: detail.networkInfo.dns },
|
||||
]
|
||||
: []
|
||||
|
||||
const handleSaveEdit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!deviceId || !editForm) return
|
||||
setEditError(null)
|
||||
updateMutation.mutate({
|
||||
id: deviceId,
|
||||
tipo: editForm.tipo as DeviceTypeValue,
|
||||
nombre: editForm.nombre.trim(),
|
||||
descripcion: editForm.descripcion.trim() || null,
|
||||
ubicacionId: editForm.ubicacionId || null,
|
||||
estado: editForm.estado as DeviceStatusValue,
|
||||
ip: editForm.ip.trim() || null,
|
||||
mac: editForm.mac.trim() || null,
|
||||
sistemaOperativo: editForm.sistemaOperativo.trim() || null,
|
||||
versionSO: editForm.versionSO.trim() || null,
|
||||
fabricante: editForm.fabricante.trim() || null,
|
||||
modelo: editForm.modelo.trim() || null,
|
||||
serial: editForm.serial.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="device-detail-title"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full max-w-2xl max-h-[90vh] overflow-hidden rounded-xl border border-white/10 bg-dark-400 shadow-xl',
|
||||
'flex flex-col'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-dark-300 border border-white/10 text-gray-400">
|
||||
{detail ? DEVICE_TYPE_ICONS[detail.tipo] ?? DEVICE_TYPE_ICONS.OTRO : DEVICE_TYPE_ICONS.OTRO}
|
||||
</div>
|
||||
<h2
|
||||
id="device-detail-title"
|
||||
className="text-lg font-semibold text-white truncate"
|
||||
>
|
||||
{detail?.nombre ?? deviceName}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{detail && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-primary-400 transition-colors"
|
||||
aria-label="Editar dispositivo"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{isLoading && !editForm && (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Cargando información del dispositivo...
|
||||
</div>
|
||||
)}
|
||||
{hasError && (
|
||||
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-3 text-sm text-red-400">
|
||||
No se pudo cargar el dispositivo. Intente de nuevo.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && editForm && device && (
|
||||
<form onSubmit={handleSaveEdit} className="space-y-4">
|
||||
{editError && (
|
||||
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-2 text-sm text-red-400">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Tipo</label>
|
||||
<select
|
||||
value={editForm.tipo}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, tipo: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{DEVICE_TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Nombre *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.nombre}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, nombre: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Descripción</label>
|
||||
<textarea
|
||||
value={editForm.descripcion}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, descripcion: e.target.value })}
|
||||
className={cn(INPUT_CLASS, 'min-h-[80px] resize-y')}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Ubicación</label>
|
||||
<select
|
||||
value={editForm.ubicacionId}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, ubicacionId: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
<option value="">Sin ubicación</option>
|
||||
{(locationsQuery.data ?? []).map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>{loc.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Estado</label>
|
||||
<select
|
||||
value={editForm.estado}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, estado: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{DEVICE_STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">IP</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.ip}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, ip: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">MAC</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.mac}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, mac: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Sistema operativo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.sistemaOperativo}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, sistemaOperativo: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Versión SO</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.versionSO}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, versionSO: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Fabricante</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.fabricante}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, fabricante: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Modelo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.modelo}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, modelo: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Número de serie</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.serial}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, serial: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updateMutation.isPending ? 'Guardando...' : 'Guardar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{detail && !isLoading && !isEditing && (
|
||||
<>
|
||||
<InfoSection title="Información del sistema" items={systemItems} />
|
||||
<InfoSection title="Información de red" items={networkItems} />
|
||||
<SoftwareList items={detail.software} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail && !isEditing && (
|
||||
<ActionBar
|
||||
deviceId={detail.id}
|
||||
onConnect={onConnect}
|
||||
onTerminal={onTerminal}
|
||||
onFiles={onFiles}
|
||||
loading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/devices/device-detail/InfoCard.tsx
Normal file
19
src/components/devices/device-detail/InfoCard.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
interface InfoCardProps {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export default function InfoCard({ label, value }: InfoCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/10 bg-dark-300/80 px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-100 truncate" title={value}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/components/devices/device-detail/InfoSection.tsx
Normal file
21
src/components/devices/device-detail/InfoSection.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import InfoCard from './InfoCard'
|
||||
|
||||
interface InfoSectionProps {
|
||||
title: string
|
||||
items: { label: string; value: string }[]
|
||||
}
|
||||
|
||||
export default function InfoSection({ title, items }: InfoSectionProps) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-300">{title}</h3>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<InfoCard key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
36
src/components/devices/device-detail/SoftwareList.tsx
Normal file
36
src/components/devices/device-detail/SoftwareList.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import type { InstalledSoftware } from '@/mocks/deviceDetailData'
|
||||
|
||||
interface SoftwareListProps {
|
||||
items: InstalledSoftware[]
|
||||
}
|
||||
|
||||
export default function SoftwareList({ items }: SoftwareListProps) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-300">
|
||||
Software instalado
|
||||
</h3>
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-white/10 bg-dark-300/80 divide-y divide-white/5">
|
||||
{items.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-500">
|
||||
Sin información de software
|
||||
</div>
|
||||
) : (
|
||||
items.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-gray-200 truncate">{s.nombre}</span>
|
||||
<span className="text-xs text-gray-500 shrink-0 tabular-nums">
|
||||
{s.version}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
91
src/components/files/FileExplorerContainer.tsx
Normal file
91
src/components/files/FileExplorerContainer.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { FolderOpen, Upload, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FileItem } from './FileRow'
|
||||
import FileTable from './FileTable'
|
||||
|
||||
interface FileExplorerContainerProps {
|
||||
selectedDeviceName: string | null
|
||||
currentPath: string
|
||||
files: FileItem[]
|
||||
onFolderClick?: (name: string) => void
|
||||
onRefresh?: () => void
|
||||
onUpload?: () => void
|
||||
}
|
||||
|
||||
export default function FileExplorerContainer({
|
||||
selectedDeviceName,
|
||||
currentPath,
|
||||
files,
|
||||
onFolderClick,
|
||||
onRefresh,
|
||||
onUpload,
|
||||
}: FileExplorerContainerProps) {
|
||||
const pathRef = useRef<HTMLDivElement>(null)
|
||||
const hasDevice = !!selectedDeviceName
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[520px] min-h-[320px] flex-col overflow-hidden rounded-xl',
|
||||
'border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'shadow-lg'
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-white/10 bg-dark-200/80 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpload}
|
||||
disabled={!hasDevice}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Subir
|
||||
</button>
|
||||
<div
|
||||
ref={pathRef}
|
||||
className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-white/10 bg-dark-300/80 px-3 py-2 font-mono text-sm text-gray-400"
|
||||
>
|
||||
{currentPath}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={!hasDevice}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Actualizar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpload}
|
||||
disabled={!hasDevice}
|
||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Subir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{!hasDevice ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
|
||||
<FolderOpen className="h-16 w-16 text-gray-600" />
|
||||
<p className="text-gray-500">
|
||||
Selecciona un dispositivo para explorar archivos
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<FileTable files={files} onFolderClick={onFolderClick} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/components/files/FileRow.tsx
Normal file
50
src/components/files/FileRow.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { Folder, FileText } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface FileItem {
|
||||
id: string
|
||||
name: string
|
||||
type: 'folder' | 'file'
|
||||
size: string | null
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
interface FileRowProps {
|
||||
file: FileItem
|
||||
onFolderClick?: (name: string) => void
|
||||
}
|
||||
|
||||
export default function FileRow({ file, onFolderClick }: FileRowProps) {
|
||||
const isFolder = file.type === 'folder'
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
'border-b border-white/5 transition-colors last:border-b-0',
|
||||
'hover:bg-white/5 cursor-pointer',
|
||||
isFolder && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => isFolder && onFolderClick?.(file.name)}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{isFolder ? (
|
||||
<Folder className="h-5 w-5 shrink-0 text-amber-400/90" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<span className="font-medium text-gray-200 truncate">{file.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500 font-mono">
|
||||
{file.size ?? '—'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">
|
||||
{isFolder ? 'Carpeta' : 'Archivo'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">{file.modifiedAt}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
35
src/components/files/FileTable.tsx
Normal file
35
src/components/files/FileTable.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import type { FileItem } from './FileRow'
|
||||
import FileRow from './FileRow'
|
||||
|
||||
interface FileTableProps {
|
||||
files: FileItem[]
|
||||
onFolderClick?: (name: string) => void
|
||||
}
|
||||
|
||||
export default function FileTable({ files, onFolderClick }: FileTableProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-dark-300/95 backdrop-blur">
|
||||
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th className="py-3 px-4">Nombre</th>
|
||||
<th className="py-3 px-4 w-28">Tamaño</th>
|
||||
<th className="py-3 px-4 w-24">Tipo</th>
|
||||
<th className="py-3 px-4 w-36">Fecha modificación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<FileRow
|
||||
key={file.id}
|
||||
file={file}
|
||||
onFolderClick={onFolderClick}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/components/headwind/CorporateAppRow.tsx
Normal file
28
src/components/headwind/CorporateAppRow.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AppDeployment } from '@/mocks/mdmDashboardData'
|
||||
|
||||
interface CorporateAppRowProps {
|
||||
app: AppDeployment
|
||||
}
|
||||
|
||||
export default function CorporateAppRow({ app }: CorporateAppRowProps) {
|
||||
const isIncomplete = app.deployed < app.total
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-200">{app.name}</p>
|
||||
<p className="text-sm text-gray-500">v{app.version}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 text-sm tabular-nums',
|
||||
isIncomplete ? 'text-amber-400' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
{app.deployed}/{app.total} dispositivos
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/components/headwind/CorporateAppsList.tsx
Normal file
33
src/components/headwind/CorporateAppsList.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDeployment } from '@/mocks/mdmDashboardData'
|
||||
import CorporateAppRow from './CorporateAppRow'
|
||||
|
||||
interface CorporateAppsListProps {
|
||||
apps: AppDeployment[]
|
||||
}
|
||||
|
||||
export default function CorporateAppsList({ apps }: CorporateAppsListProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Apps Corporativas
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{apps.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Sin apps desplegadas
|
||||
</div>
|
||||
) : (
|
||||
apps.map((app) => (
|
||||
<div key={app.id} className="px-4">
|
||||
<CorporateAppRow app={app} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/headwind/DeviceItem.tsx
Normal file
38
src/components/headwind/DeviceItem.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Device, DeviceStatus } from '@/mocks/mdmDashboardData'
|
||||
|
||||
const statusConfig: Record<DeviceStatus, { dot: string; text: string }> = {
|
||||
online: { dot: 'bg-green-500', text: 'text-green-400' },
|
||||
offline: { dot: 'bg-red-500', text: 'text-red-400' },
|
||||
kiosk: { dot: 'bg-blue-500', text: 'text-blue-400' },
|
||||
}
|
||||
|
||||
const statusLabel: Record<DeviceStatus, string> = {
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
kiosk: 'Kiosk',
|
||||
}
|
||||
|
||||
interface DeviceItemProps {
|
||||
device: Device
|
||||
}
|
||||
|
||||
export default function DeviceItem({ device }: DeviceItemProps) {
|
||||
const config = statusConfig[device.status]
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-200">{device.name}</p>
|
||||
<p className="mt-0.5 text-sm text-gray-500">
|
||||
Android {device.androidVersion} · Batería {device.batteryPercent}%
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn('ml-4 inline-flex items-center gap-1.5 text-sm shrink-0', config.text)}>
|
||||
<span className={cn('h-2 w-2 rounded-full', config.dot)} />
|
||||
{statusLabel[device.status]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/headwind/HeadwindMetricCard.tsx
Normal file
45
src/components/headwind/HeadwindMetricCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AccentColor = 'green' | 'blue' | 'cyan' | 'amber'
|
||||
|
||||
interface HeadwindMetricCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
subtitle: string
|
||||
accent?: AccentColor
|
||||
}
|
||||
|
||||
const accentColors: Record<AccentColor, string> = {
|
||||
green: 'text-emerald-400',
|
||||
blue: 'text-blue-400',
|
||||
cyan: 'text-cyan-400',
|
||||
amber: 'text-amber-400',
|
||||
}
|
||||
|
||||
export default function HeadwindMetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
accent = 'cyan',
|
||||
}: HeadwindMetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 overflow-hidden',
|
||||
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'flex flex-col justify-center px-5 py-6',
|
||||
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/components/headwind/MobileDeviceList.tsx
Normal file
33
src/components/headwind/MobileDeviceList.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { Device } from '@/mocks/mdmDashboardData'
|
||||
import DeviceItem from './DeviceItem'
|
||||
|
||||
interface MobileDeviceListProps {
|
||||
devices: Device[]
|
||||
}
|
||||
|
||||
export default function MobileDeviceList({ devices }: MobileDeviceListProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Dispositivos Móviles
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{devices.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Sin dispositivos
|
||||
</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<div key={device.id} className="px-4">
|
||||
<DeviceItem device={device} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -67,13 +67,13 @@ const menuConfig: SidebarMenuSection[] = [
|
||||
items: [
|
||||
{
|
||||
label: 'LibreNMS',
|
||||
href: '/configuracion',
|
||||
href: '/librenms',
|
||||
icon: <Network className="w-5 h-5" />,
|
||||
badge: { type: 'green', value: 'OK' },
|
||||
},
|
||||
{
|
||||
label: 'Headwind MDM',
|
||||
href: '/configuracion',
|
||||
href: '/headwind',
|
||||
icon: <Smartphone className="w-5 h-5" />,
|
||||
badge: { type: 'blue', value: 12 },
|
||||
},
|
||||
@@ -102,11 +102,12 @@ const menuConfig: SidebarMenuSection[] = [
|
||||
interface SidebarProps {
|
||||
activeAlertsCount?: number
|
||||
devicesCount?: number
|
||||
sessionsCount?: number
|
||||
open?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function Sidebar({ activeAlertsCount, devicesCount, open = false, onClose }: SidebarProps) {
|
||||
export default function Sidebar({ activeAlertsCount, devicesCount, sessionsCount, open = false, onClose }: SidebarProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
const isActive = (href: string) => {
|
||||
@@ -122,6 +123,10 @@ export default function Sidebar({ activeAlertsCount, devicesCount, open = false,
|
||||
if (item.href === '/devices' && devicesCount !== undefined) {
|
||||
return { type: 'red', value: devicesCount }
|
||||
}
|
||||
if (item.href === '/sesiones' && sessionsCount !== undefined) {
|
||||
if (sessionsCount === 0) return undefined
|
||||
return { type: 'red', value: sessionsCount }
|
||||
}
|
||||
return item.badge
|
||||
}
|
||||
|
||||
|
||||
33
src/components/librenms/AlertItem.tsx
Normal file
33
src/components/librenms/AlertItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type AlertSeverity = 'critical' | 'warning' | 'info'
|
||||
|
||||
export interface SnmpAlert {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
severity: AlertSeverity
|
||||
}
|
||||
|
||||
const severityStyles: Record<AlertSeverity, string> = {
|
||||
critical: 'text-red-400',
|
||||
warning: 'text-yellow-400',
|
||||
info: 'text-blue-400',
|
||||
}
|
||||
|
||||
interface AlertItemProps {
|
||||
alert: SnmpAlert
|
||||
}
|
||||
|
||||
export default function AlertItem({ alert }: AlertItemProps) {
|
||||
return (
|
||||
<div className="border-b border-white/5 py-4 last:border-b-0">
|
||||
<p className={cn('font-medium', severityStyles[alert.severity])}>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{alert.detail}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/components/librenms/AlertList.tsx
Normal file
31
src/components/librenms/AlertList.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import type { SnmpAlert } from './AlertItem'
|
||||
import AlertItem from './AlertItem'
|
||||
|
||||
interface AlertListProps {
|
||||
alerts: SnmpAlert[]
|
||||
}
|
||||
|
||||
export default function AlertList({ alerts }: AlertListProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Alertas SNMP Recientes
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Sin alertas
|
||||
</div>
|
||||
) : (
|
||||
alerts.map((alert) => (
|
||||
<AlertItem key={alert.id} alert={alert} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/components/librenms/DeviceList.tsx
Normal file
33
src/components/librenms/DeviceList.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { NetworkDevice } from './DeviceRow'
|
||||
import DeviceRow from './DeviceRow'
|
||||
|
||||
interface DeviceListProps {
|
||||
devices: NetworkDevice[]
|
||||
}
|
||||
|
||||
export default function DeviceList({ devices }: DeviceListProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Dispositivos de Red
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{devices.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Sin dispositivos
|
||||
</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<div key={device.id} className="px-4">
|
||||
<DeviceRow device={device} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/components/librenms/DeviceRow.tsx
Normal file
37
src/components/librenms/DeviceRow.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import type { DeviceStatus } from './StatusBadge'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
export interface NetworkDevice {
|
||||
id: string
|
||||
name: string
|
||||
model?: string
|
||||
status: DeviceStatus
|
||||
}
|
||||
|
||||
const statusLabel: Record<DeviceStatus, string> = {
|
||||
online: 'Online',
|
||||
warning: 'Warning',
|
||||
critical: 'Critical',
|
||||
}
|
||||
|
||||
interface DeviceRowProps {
|
||||
device: NetworkDevice
|
||||
}
|
||||
|
||||
export default function DeviceRow({ device }: DeviceRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-gray-200">{device.name}</span>
|
||||
{device.model && (
|
||||
<span className="ml-2 text-sm text-gray-500">– {device.model}</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={device.status} label={statusLabel[device.status]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/librenms/LibrenmsMetricCard.tsx
Normal file
45
src/components/librenms/LibrenmsMetricCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AccentColor = 'green' | 'cyan' | 'yellow' | 'blue'
|
||||
|
||||
interface LibrenmsMetricCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
subtitle: string
|
||||
accent?: AccentColor
|
||||
}
|
||||
|
||||
const accentColors: Record<AccentColor, string> = {
|
||||
green: 'text-emerald-400',
|
||||
cyan: 'text-cyan-400',
|
||||
yellow: 'text-amber-400',
|
||||
blue: 'text-blue-400',
|
||||
}
|
||||
|
||||
export default function LibrenmsMetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
accent = 'cyan',
|
||||
}: LibrenmsMetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 overflow-hidden',
|
||||
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'flex flex-col justify-center px-5 py-6',
|
||||
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/components/librenms/LibrenmsRow.tsx
Normal file
49
src/components/librenms/LibrenmsRow.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface LibrenmsNodeItem {
|
||||
id: string
|
||||
name: string
|
||||
ip: string
|
||||
type: string
|
||||
status: 'up' | 'down' | 'warning'
|
||||
lastUpdate: string
|
||||
}
|
||||
|
||||
interface LibrenmsRowProps {
|
||||
item: LibrenmsNodeItem
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
up: { label: 'En línea', dot: 'bg-emerald-500' },
|
||||
down: { label: 'Fuera de línea', dot: 'bg-red-500' },
|
||||
warning: { label: 'Alerta', dot: 'bg-amber-500' },
|
||||
}
|
||||
|
||||
export default function LibrenmsRow({ item }: LibrenmsRowProps) {
|
||||
const status = statusConfig[item.status]
|
||||
|
||||
return (
|
||||
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5 cursor-pointer">
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium text-gray-200">{item.name}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-sm text-gray-400">
|
||||
{item.ip}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="rounded-full bg-cyan-500/10 px-3 py-1 text-xs text-cyan-400">
|
||||
{item.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('h-2 w-2 rounded-full', status.dot)} />
|
||||
<span className="text-sm text-gray-400">{status.label}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
26
src/components/librenms/StatusBadge.tsx
Normal file
26
src/components/librenms/StatusBadge.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type DeviceStatus = 'online' | 'warning' | 'critical'
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: DeviceStatus
|
||||
label: string
|
||||
}
|
||||
|
||||
const statusConfig: Record<DeviceStatus, { dot: string; text: string }> = {
|
||||
online: { dot: 'bg-green-500', text: 'text-green-400' },
|
||||
warning: { dot: 'bg-yellow-500', text: 'text-yellow-400' },
|
||||
critical: { dot: 'bg-red-500', text: 'text-red-400' },
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status, label }: StatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', config.text)}>
|
||||
<span className={cn('h-2 w-2 shrink-0 rounded-full', config.dot)} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
57
src/components/performance/LineChart.tsx
Normal file
57
src/components/performance/LineChart.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LineChartProps {
|
||||
className?: string
|
||||
height?: number
|
||||
data?: number[]
|
||||
}
|
||||
|
||||
export default function LineChart({
|
||||
className,
|
||||
height = 160,
|
||||
data = [],
|
||||
}: LineChartProps) {
|
||||
const points = data.length >= 2
|
||||
? data
|
||||
.map((v, i) => {
|
||||
const x = (i / Math.max(1, data.length - 1)) * 100
|
||||
const y = 100 - Math.min(100, Math.max(0, v))
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
: '0,100 100,100'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg bg-dark-200/80 border border-white/5 overflow-hidden',
|
||||
className
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{data.length >= 2 ? (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="w-full h-full text-cyan-500/30"
|
||||
>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={points}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-600 text-sm">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
src/components/performance/MetricCard.tsx
Normal file
72
src/components/performance/MetricCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import LineChart from './LineChart'
|
||||
|
||||
export interface MetricCardFooterRow {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
value: string
|
||||
valueSuffix?: string
|
||||
footerStats: MetricCardFooterRow[]
|
||||
chartData?: number[]
|
||||
highUsage?: boolean
|
||||
}
|
||||
|
||||
export default function MetricCard({
|
||||
title,
|
||||
value,
|
||||
valueSuffix,
|
||||
footerStats,
|
||||
chartData,
|
||||
highUsage = false,
|
||||
}: MetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-xl border border-white/10 overflow-hidden',
|
||||
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'transition-all duration-200 hover:border-white/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between px-4 py-3 border-b border-white/5">
|
||||
<span className="text-sm font-medium text-gray-400">{title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-lg font-semibold tabular-nums transition-colors duration-300',
|
||||
highUsage ? 'text-red-400' : 'text-cyan-400'
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
{valueSuffix != null && (
|
||||
<span className="text-sm font-normal text-gray-500 ml-0.5">
|
||||
{valueSuffix}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-4 pt-3">
|
||||
<LineChart height={150} data={chartData ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-1.5 px-4 py-3 border-t border-white/5">
|
||||
{footerStats.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex justify-between text-xs"
|
||||
>
|
||||
<span className="text-gray-500">{row.label}</span>
|
||||
<span className="font-mono text-gray-400 tabular-nums">
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/performance/ProcessRow.tsx
Normal file
36
src/components/performance/ProcessRow.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ProcessItem {
|
||||
id: string
|
||||
name: string
|
||||
pid: number
|
||||
cpu: number
|
||||
memory: string
|
||||
state: string
|
||||
}
|
||||
|
||||
interface ProcessRowProps {
|
||||
process: ProcessItem
|
||||
}
|
||||
|
||||
export default function ProcessRow({ process }: ProcessRowProps) {
|
||||
return (
|
||||
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
|
||||
<td className="py-2.5 px-4 text-sm text-gray-200 truncate max-w-[200px]">
|
||||
{process.name}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 font-mono text-sm text-gray-400 tabular-nums">
|
||||
{process.pid}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 font-mono text-sm text-cyan-400 tabular-nums">
|
||||
{process.cpu}%
|
||||
</td>
|
||||
<td className="py-2.5 px-4 font-mono text-sm text-gray-400">
|
||||
{process.memory}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-sm text-gray-500">{process.state}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
65
src/components/performance/ProcessTable.tsx
Normal file
65
src/components/performance/ProcessTable.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { ProcessItem } from './ProcessRow'
|
||||
import ProcessRow from './ProcessRow'
|
||||
|
||||
interface ProcessTableProps {
|
||||
processes: ProcessItem[]
|
||||
noDevice?: boolean
|
||||
}
|
||||
|
||||
export default function ProcessTable({ processes, noDevice }: ProcessTableProps) {
|
||||
if (noDevice) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Procesos Activos (Top 10 por CPU)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
Selecciona un dispositivo
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Procesos Activos (Top 10 por CPU)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th className="py-3 px-4">Proceso</th>
|
||||
<th className="py-3 px-4 w-20">PID</th>
|
||||
<th className="py-3 px-4 w-24">CPU %</th>
|
||||
<th className="py-3 px-4 w-24">Memoria</th>
|
||||
<th className="py-3 px-4 w-24">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processes.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="py-8 text-center text-sm text-gray-500"
|
||||
>
|
||||
Sin datos
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processes.map((p) => (
|
||||
<ProcessRow key={p.id} process={p} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
src/components/reportes/DateRangeFilter.tsx
Normal file
60
src/components/reportes/DateRangeFilter.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
export interface DateRangeValue {
|
||||
desde: string
|
||||
hasta: string
|
||||
}
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
value: DateRangeValue
|
||||
onChange: (value: DateRangeValue) => void
|
||||
}
|
||||
|
||||
export default function DateRangeFilter({ value, onChange }: DateRangeFilterProps) {
|
||||
const handleDesdeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const desde = e.target.value
|
||||
const hasta = value.hasta && desde > value.hasta ? desde : value.hasta
|
||||
onChange({ desde, hasta })
|
||||
}
|
||||
|
||||
const handleHastaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const hasta = e.target.value
|
||||
onChange({ ...value, hasta })
|
||||
}
|
||||
|
||||
const isHastaBeforeDesde =
|
||||
value.desde && value.hasta && value.hasta < value.desde
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
Desde
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={value.desde}
|
||||
onChange={handleDesdeChange}
|
||||
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
Hasta
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={value.hasta}
|
||||
onChange={handleHastaChange}
|
||||
min={value.desde || undefined}
|
||||
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||
/>
|
||||
</label>
|
||||
{isHastaBeforeDesde && (
|
||||
<p className="text-sm text-amber-400" role="alert">
|
||||
La fecha Hasta no puede ser anterior a Desde
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
src/components/reportes/ReportCard.tsx
Normal file
62
src/components/reportes/ReportCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FileText, FileSpreadsheet } from 'lucide-react'
|
||||
|
||||
interface ReportCardProps {
|
||||
title: string
|
||||
description: string
|
||||
onExportPdf: () => void
|
||||
onExportExcel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function ReportCard({
|
||||
title,
|
||||
description,
|
||||
onExportPdf,
|
||||
onExportExcel,
|
||||
loading = false,
|
||||
}: ReportCardProps) {
|
||||
const btnBase =
|
||||
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50'
|
||||
const btnPrimary =
|
||||
'bg-cyan-600 text-white hover:bg-cyan-500'
|
||||
const btnOutlined =
|
||||
'border border-white/10 text-gray-300 hover:bg-white/5 hover:text-gray-200'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 overflow-hidden',
|
||||
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'flex flex-col'
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-white/10 px-4 py-4">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<p className="mt-2 text-sm text-gray-400">{description}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportPdf}
|
||||
disabled={loading}
|
||||
className={cn(btnBase, btnPrimary)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Exportar PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportExcel}
|
||||
disabled={loading}
|
||||
className={cn(btnBase, btnOutlined)}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/components/sessions/SessionCard.tsx
Normal file
95
src/components/sessions/SessionCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { Monitor, Terminal, FolderOpen } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SessionTypeLabel = 'Escritorio Remoto' | 'Terminal' | 'Archivos'
|
||||
|
||||
export interface SessionCardProps {
|
||||
id: string
|
||||
deviceName: string
|
||||
userEmail: string
|
||||
sessionType: SessionTypeLabel
|
||||
duration: string
|
||||
onEnd?: (id: string) => void
|
||||
isEnding?: boolean
|
||||
}
|
||||
|
||||
const typeConfig: Record<
|
||||
SessionTypeLabel,
|
||||
{ icon: React.ReactNode; bgClass: string }
|
||||
> = {
|
||||
'Escritorio Remoto': {
|
||||
icon: <Monitor className="h-5 w-5" />,
|
||||
bgClass: 'bg-cyan-500/15 text-cyan-400',
|
||||
},
|
||||
Terminal: {
|
||||
icon: <Terminal className="h-5 w-5" />,
|
||||
bgClass: 'bg-emerald-500/15 text-emerald-400',
|
||||
},
|
||||
Archivos: {
|
||||
icon: <FolderOpen className="h-5 w-5" />,
|
||||
bgClass: 'bg-amber-500/15 text-amber-400',
|
||||
},
|
||||
}
|
||||
|
||||
export default function SessionCard({
|
||||
id,
|
||||
deviceName,
|
||||
userEmail,
|
||||
sessionType,
|
||||
duration,
|
||||
onEnd,
|
||||
isEnding,
|
||||
}: SessionCardProps) {
|
||||
const config = typeConfig[sessionType]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5 transition-all duration-200',
|
||||
'sm:flex-row sm:items-center sm:justify-between',
|
||||
'hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
|
||||
config.bgClass
|
||||
)}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-lg font-semibold text-gray-100 truncate">
|
||||
{deviceName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 truncate">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<span className="font-mono text-sm font-medium text-cyan-400 tabular-nums">
|
||||
{duration}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{sessionType}</span>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 sm:pl-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEnd?.(id)}
|
||||
disabled={isEnding}
|
||||
className={cn(
|
||||
'rounded-lg border border-red-500/60 px-4 py-2 text-sm font-medium text-red-400',
|
||||
'transition-colors hover:bg-red-500/15 hover:border-red-500/80',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isEnding ? 'Terminando…' : 'Terminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
src/components/software/SoftwareRow.tsx
Normal file
48
src/components/software/SoftwareRow.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SoftwareItem {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
vendor: string
|
||||
installations: number
|
||||
lastUpdate: string
|
||||
licensed?: boolean
|
||||
needsUpdate?: boolean
|
||||
}
|
||||
|
||||
interface SoftwareRowProps {
|
||||
item: SoftwareItem
|
||||
}
|
||||
|
||||
export default function SoftwareRow({ item }: SoftwareRowProps) {
|
||||
return (
|
||||
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-200">{item.name}</span>
|
||||
{item.licensed && (
|
||||
<span className="rounded bg-cyan-500/20 px-1.5 py-0.5 text-xs text-cyan-400">
|
||||
Licencia
|
||||
</span>
|
||||
)}
|
||||
{item.needsUpdate && (
|
||||
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-400">
|
||||
Actualizar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-sm text-gray-400">
|
||||
{item.version}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-400">{item.vendor}</td>
|
||||
<td className="py-3 px-4 font-mono text-sm text-cyan-400 tabular-nums">
|
||||
{item.installations}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
42
src/components/software/SoftwareTable.tsx
Normal file
42
src/components/software/SoftwareTable.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import type { SoftwareItem } from './SoftwareRow'
|
||||
import SoftwareRow from './SoftwareRow'
|
||||
|
||||
interface SoftwareTableProps {
|
||||
items: SoftwareItem[]
|
||||
}
|
||||
|
||||
export default function SoftwareTable({ items }: SoftwareTableProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wide text-gray-400">
|
||||
<th className="py-3 px-4">Nombre</th>
|
||||
<th className="py-3 px-4 w-40">Versión</th>
|
||||
<th className="py-3 px-4 w-48">Editor</th>
|
||||
<th className="py-3 px-4 w-28">Instalaciones</th>
|
||||
<th className="py-3 px-4 w-36">Última actualización</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="py-12 text-center text-sm text-gray-500"
|
||||
>
|
||||
No hay resultados
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<SoftwareRow key={item.id} item={item} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/components/software/SummaryMetricCard.tsx
Normal file
31
src/components/software/SummaryMetricCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SummaryMetricCardProps {
|
||||
value: number
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SummaryMetricCard({
|
||||
value,
|
||||
label,
|
||||
className,
|
||||
}: SummaryMetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'flex flex-col items-center justify-center py-6 px-4',
|
||||
'transition-all duration-200 hover:border-white/20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="text-4xl font-bold text-cyan-400 tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-2 text-sm text-gray-400">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/terminal/QuickCommands.tsx
Normal file
36
src/components/terminal/QuickCommands.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
export interface QuickCommandsProps {
|
||||
onSelectCommand: (command: string) => void
|
||||
}
|
||||
|
||||
const COMMANDS = [
|
||||
'systeminfo',
|
||||
'ipconfig',
|
||||
'tasklist',
|
||||
'netstat',
|
||||
'CPU Info',
|
||||
'RAM Info',
|
||||
'dir C:\\',
|
||||
'hostname',
|
||||
]
|
||||
|
||||
export default function QuickCommands({ onSelectCommand }: QuickCommandsProps) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">Comandos Rápidos</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
|
||||
{COMMANDS.map((cmd) => (
|
||||
<button
|
||||
key={cmd}
|
||||
type="button"
|
||||
onClick={() => onSelectCommand(cmd)}
|
||||
className="shrink-0 rounded-lg border border-white/10 bg-dark-300/80 px-4 py-2 font-mono text-xs text-gray-300 transition-colors hover:border-cyan-500/30 hover:bg-dark-200 hover:text-cyan-400"
|
||||
>
|
||||
{cmd}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
153
src/components/terminal/TerminalWindow.tsx
Normal file
153
src/components/terminal/TerminalWindow.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { Send, Copy } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TerminalWindowProps {
|
||||
connectedDeviceName: string | null
|
||||
outputLines: string[]
|
||||
command: string
|
||||
onCommandChange: (value: string) => void
|
||||
onSendCommand: () => void
|
||||
onClear: () => void
|
||||
onCopy: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ASCII_PLACEHOLDER = `
|
||||
__ __ _ ____ _ _
|
||||
| \\/ | ___ ___| |__ / ___|___ _ __ | |_ _ __ __ _| |
|
||||
| |\\/| |/ _ \\/ __| '_ \\| | / _ \\ '_ \\| __| '__/ _\` | |
|
||||
| | | | __/\\__ \\ | | | |__| __/ | | | |_| | | (_| | |
|
||||
|_| |_|\\___||___/_| |_|\\____\\___|_| |_|\\__|_| \\__,_|_|
|
||||
`
|
||||
|
||||
export default function TerminalWindow({
|
||||
connectedDeviceName,
|
||||
outputLines,
|
||||
command,
|
||||
onCommandChange,
|
||||
onSendCommand,
|
||||
onClear,
|
||||
onCopy,
|
||||
disabled = false,
|
||||
}: TerminalWindowProps) {
|
||||
const outputEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
outputEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [outputLines])
|
||||
|
||||
const isEmpty = outputLines.length === 0
|
||||
const showPlaceholder = !connectedDeviceName
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onSendCommand()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[520px] min-h-[320px] flex-col overflow-hidden rounded-xl',
|
||||
'border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'shadow-lg'
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-white/10 bg-dark-200/80 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-3 w-3 rounded-full bg-red-500/90" />
|
||||
<span className="h-3 w-3 rounded-full bg-amber-500/90" />
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500/90" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-400">
|
||||
bash — {connectedDeviceName ?? 'No conectado'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 font-mono text-sm">
|
||||
{showPlaceholder ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<pre className="whitespace-pre text-cyan-400/80">{ASCII_PLACEHOLDER}</pre>
|
||||
<p className="mt-6 text-gray-500">
|
||||
Selecciona un dispositivo para iniciar una sesión de terminal.
|
||||
</p>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<div ref={outputEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{outputLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
line.startsWith('$ ')
|
||||
? 'text-cyan-400'
|
||||
: line.startsWith('> ')
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-300'
|
||||
)}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={outputEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-white/10 bg-dark-200/60 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 font-mono text-cyan-400">$</span>
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(e) => onCommandChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe un comando..."
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 bg-transparent font-mono text-gray-200 placeholder-gray-500',
|
||||
'focus:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSendCommand}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-lg bg-cyan-600 px-4 py-2',
|
||||
'text-white transition-all hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,14 @@ export function formatUptime(seconds: number): string {
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
export function formatDurationSeconds(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)}`
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = new Date(date)
|
||||
return d.toLocaleDateString('es-MX', {
|
||||
|
||||
83
src/mocks/dashboardData.ts
Normal file
83
src/mocks/dashboardData.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export interface DashboardStats {
|
||||
totalDispositivos: number
|
||||
dispositivosOnline: number
|
||||
dispositivosOffline: number
|
||||
dispositivosAlerta: number
|
||||
secondary?: {
|
||||
total?: string
|
||||
online?: string
|
||||
offline?: string
|
||||
alerta?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceStatusBreakdown {
|
||||
online: number
|
||||
offline: number
|
||||
advertencia: number
|
||||
}
|
||||
|
||||
export type RecentActivityType = 'login' | 'alert' | 'connection' | 'disconnection'
|
||||
|
||||
export type AlertSeverity = 'INFO' | 'WARNING' | 'CRITICAL'
|
||||
|
||||
export interface RecentActivityItem {
|
||||
id: string
|
||||
type: RecentActivityType
|
||||
description: string
|
||||
deviceName: string
|
||||
timestamp: Date
|
||||
severity?: AlertSeverity
|
||||
}
|
||||
|
||||
export interface SystemHealthMetric {
|
||||
label: string
|
||||
value: number
|
||||
unit: string
|
||||
status: 'healthy' | 'warning' | 'critical'
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
cpu: SystemHealthMetric
|
||||
ram: SystemHealthMetric
|
||||
network: SystemHealthMetric
|
||||
}
|
||||
|
||||
export type QuickConnectionStatus = 'online' | 'advertencia' | 'offline'
|
||||
|
||||
export interface QuickConnectionItem {
|
||||
id: string
|
||||
name: string
|
||||
status: QuickConnectionStatus
|
||||
}
|
||||
|
||||
export const MOCK_DASHBOARD_SECONDARY = {
|
||||
total: '+2 este mes',
|
||||
online: '60% disponibilidad',
|
||||
offline: '-1 vs ayer',
|
||||
alerta: '2 requieren atención',
|
||||
}
|
||||
|
||||
export const MOCK_RECENT_ACTIVITY: RecentActivityItem[] = [
|
||||
{ id: '1', type: 'login', description: 'Sesión iniciada', deviceName: 'PC-ADMIN-01', timestamp: new Date(Date.now() - 1000 * 60 * 2) },
|
||||
{ id: '2', type: 'alert', description: 'CPU alta detectada', deviceName: 'LAPTOP-VENTAS', timestamp: new Date(Date.now() - 1000 * 60 * 15) },
|
||||
{ id: '3', type: 'connection', description: 'Dispositivo conectado', deviceName: 'SRV-PRINCIPAL', timestamp: new Date(Date.now() - 1000 * 60 * 32) },
|
||||
{ id: '4', type: 'disconnection', description: 'Conexión perdida', deviceName: 'SRV-BACKUP', timestamp: new Date(Date.now() - 1000 * 60 * 120) },
|
||||
{ id: '5', type: 'alert', description: 'Actualización pendiente', deviceName: 'PC-OFICINA-02', timestamp: new Date(Date.now() - 1000 * 60 * 45) },
|
||||
{ id: '6', type: 'connection', description: 'Dispositivo conectado', deviceName: 'ROUTER-PRINCIPAL', timestamp: new Date(Date.now() - 1000 * 60 * 90) },
|
||||
]
|
||||
|
||||
export const MOCK_SYSTEM_HEALTH: SystemHealth = {
|
||||
cpu: { label: 'CPU Promedio', value: 42, unit: '%', status: 'healthy' },
|
||||
ram: { label: 'RAM Promedio', value: 68, unit: '%', status: 'warning' },
|
||||
network: { label: 'Red', value: 125, unit: 'MB/s', status: 'healthy' },
|
||||
}
|
||||
|
||||
export const MOCK_QUICK_CONNECTIONS: QuickConnectionItem[] = [
|
||||
{ id: '1', name: 'SRV-PRINCIPAL', status: 'online' },
|
||||
{ id: '2', name: 'PC-ADMIN-01', status: 'online' },
|
||||
{ id: '3', name: 'LAPTOP-VENTAS', status: 'advertencia' },
|
||||
{ id: '4', name: 'ROUTER-PRINCIPAL', status: 'online' },
|
||||
{ id: '5', name: 'SW-CORE-01', status: 'online' },
|
||||
{ id: '6', name: 'SRV-BACKUP', status: 'offline' },
|
||||
]
|
||||
103
src/mocks/deviceDetailData.ts
Normal file
103
src/mocks/deviceDetailData.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
export interface DeviceSystemInfo {
|
||||
sistemaOperativo: string
|
||||
procesador: string
|
||||
memoriaRam: string
|
||||
almacenamiento: string
|
||||
almacenamientoUsoPercent?: number
|
||||
}
|
||||
|
||||
export interface DeviceNetworkInfo {
|
||||
direccionIp: string
|
||||
direccionMac: string
|
||||
gateway: string
|
||||
dns: string
|
||||
}
|
||||
|
||||
export interface InstalledSoftware {
|
||||
id: string
|
||||
nombre: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface DeviceDetail {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
systemInfo: DeviceSystemInfo
|
||||
networkInfo: DeviceNetworkInfo
|
||||
software: InstalledSoftware[]
|
||||
}
|
||||
|
||||
export function mapDeviceToDetail(device: {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
sistemaOperativo?: string | null
|
||||
versionSO?: string | null
|
||||
cpu?: string | null
|
||||
ram?: number | null
|
||||
disco?: number | null
|
||||
discoUsage?: number | null
|
||||
ip?: string | null
|
||||
mac?: string | null
|
||||
software?: { id: string; nombre: string; version: string | null }[]
|
||||
}): DeviceDetail {
|
||||
const ramStr = device.ram != null ? `${device.ram} MB` : '—'
|
||||
const discoStr = device.disco != null ? `${device.disco} GB` : '—'
|
||||
return {
|
||||
id: device.id,
|
||||
nombre: device.nombre,
|
||||
tipo: device.tipo,
|
||||
systemInfo: {
|
||||
sistemaOperativo: device.sistemaOperativo?.trim() || '—',
|
||||
procesador: device.cpu?.trim() || '—',
|
||||
memoriaRam: ramStr,
|
||||
almacenamiento: discoStr,
|
||||
almacenamientoUsoPercent: device.discoUsage ?? undefined,
|
||||
},
|
||||
networkInfo: {
|
||||
direccionIp: device.ip?.trim() || '—',
|
||||
direccionMac: device.mac?.trim() || '—',
|
||||
gateway: '—',
|
||||
dns: '—',
|
||||
},
|
||||
software: (device.software ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
nombre: s.nombre,
|
||||
version: s.version ?? '—',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchDeviceDetailMock(deviceId: string): Promise<DeviceDetail> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
id: deviceId,
|
||||
nombre: 'PC-RECEPCION-01',
|
||||
tipo: 'PC',
|
||||
systemInfo: {
|
||||
sistemaOperativo: 'Windows 11 Pro',
|
||||
procesador: 'Intel Core i5-12400',
|
||||
memoriaRam: '16384 MB',
|
||||
almacenamiento: '512 GB',
|
||||
almacenamientoUsoPercent: 62,
|
||||
},
|
||||
networkInfo: {
|
||||
direccionIp: '192.168.1.101',
|
||||
direccionMac: '00:1A:2B:3C:4D:5E',
|
||||
gateway: '192.168.1.1',
|
||||
dns: '8.8.8.8',
|
||||
},
|
||||
software: [
|
||||
{ id: '1', nombre: 'Microsoft Office 365', version: '16.0.17029' },
|
||||
{ id: '2', nombre: 'Google Chrome', version: '120.0.6099' },
|
||||
{ id: '3', nombre: 'Adobe Acrobat Reader', version: '23.006' },
|
||||
{ id: '4', nombre: 'WinRAR', version: '6.24' },
|
||||
],
|
||||
}),
|
||||
400
|
||||
)
|
||||
})
|
||||
}
|
||||
58
src/mocks/mdmDashboardData.ts
Normal file
58
src/mocks/mdmDashboardData.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type DeviceStatus = 'online' | 'offline' | 'kiosk'
|
||||
|
||||
export interface Device {
|
||||
id: string
|
||||
name: string
|
||||
androidVersion: string
|
||||
batteryPercent: number
|
||||
status: DeviceStatus
|
||||
}
|
||||
|
||||
export interface AppDeployment {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
deployed: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalAndroidDevices: number
|
||||
deployedApps: number
|
||||
activePolicies: number
|
||||
averageBatteryPercent: number
|
||||
}
|
||||
|
||||
export const MOCK_DASHBOARD_STATS: DashboardStats = {
|
||||
totalAndroidDevices: 12,
|
||||
deployedApps: 8,
|
||||
activePolicies: 5,
|
||||
averageBatteryPercent: 78,
|
||||
}
|
||||
|
||||
export const MOCK_DEVICES: Device[] = [
|
||||
{ id: '1', name: 'Samsung Galaxy A54 – Ventas01', androidVersion: '14', batteryPercent: 92, status: 'online' },
|
||||
{ id: '2', name: 'Samsung Galaxy A54 – Ventas02', androidVersion: '14', batteryPercent: 45, status: 'online' },
|
||||
{ id: '3', name: 'Xiaomi Redmi Note 12 – Almacén', androidVersion: '13', batteryPercent: 100, status: 'kiosk' },
|
||||
{ id: '4', name: 'Motorola Moto G – Reparto01', androidVersion: '13', batteryPercent: 12, status: 'offline' },
|
||||
{ id: '5', name: 'Samsung Galaxy A34 – Oficina', androidVersion: '14', batteryPercent: 78, status: 'online' },
|
||||
]
|
||||
|
||||
export const MOCK_APP_DEPLOYMENTS: AppDeployment[] = [
|
||||
{ id: '1', name: 'App Corporativa Ventas', version: '2.1.0', deployed: 12, total: 12 },
|
||||
{ id: '2', name: 'Headwind Kiosk', version: '1.4.2', deployed: 10, total: 12 },
|
||||
{ id: '3', name: 'Authenticator', version: '6.2.1', deployed: 12, total: 12 },
|
||||
{ id: '4', name: 'Microsoft Teams', version: '1416/1.0.0', deployed: 8, total: 12 },
|
||||
]
|
||||
|
||||
export function getMdmDashboardData(): {
|
||||
stats: DashboardStats
|
||||
devices: Device[]
|
||||
appDeployments: AppDeployment[]
|
||||
} {
|
||||
return {
|
||||
stats: MOCK_DASHBOARD_STATS,
|
||||
devices: MOCK_DEVICES,
|
||||
appDeployments: MOCK_APP_DEPLOYMENTS,
|
||||
}
|
||||
}
|
||||
100
src/mocks/reportService.ts
Normal file
100
src/mocks/reportService.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export interface ReportFilters {
|
||||
desde: string
|
||||
hasta: string
|
||||
}
|
||||
|
||||
export interface InventoryReportItem {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
cliente: string
|
||||
ip: string
|
||||
so: string
|
||||
estado: string
|
||||
}
|
||||
|
||||
export interface InventoryReport {
|
||||
periodo: { desde: string; hasta: string }
|
||||
total: number
|
||||
items: InventoryReportItem[]
|
||||
}
|
||||
|
||||
export interface ResourceUsageReportDevice {
|
||||
dispositivo: string
|
||||
cliente: string
|
||||
cpuPromedio: number
|
||||
memoriaPromedio: number
|
||||
redPromedioMB: number
|
||||
}
|
||||
|
||||
export interface ResourceUsageReport {
|
||||
periodo: { desde: string; hasta: string }
|
||||
dispositivos: ResourceUsageReportDevice[]
|
||||
}
|
||||
|
||||
export interface AlertsReportItem {
|
||||
id: string
|
||||
fecha: string
|
||||
titulo: string
|
||||
severidad: string
|
||||
estado: string
|
||||
dispositivo: string
|
||||
resueltoEn: string | null
|
||||
}
|
||||
|
||||
export interface AlertsReport {
|
||||
periodo: { desde: string; hasta: string }
|
||||
total: number
|
||||
items: AlertsReportItem[]
|
||||
}
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// TODO: replace with trpc.reportes.inventario (and optional date filter if backend supports it)
|
||||
export async function fetchInventoryReport(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<InventoryReport> {
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
return {
|
||||
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
|
||||
total: 42,
|
||||
items: [
|
||||
{ id: '1', nombre: 'PC-Oficina-01', tipo: 'PC', cliente: 'Cliente A', ip: '192.168.1.10', so: 'Windows 11', estado: 'Activo' },
|
||||
{ id: '2', nombre: 'LAPTOP-Ventas-02', tipo: 'LAPTOP', cliente: 'Cliente A', ip: '192.168.1.22', so: 'Windows 11', estado: 'Activo' },
|
||||
{ id: '3', nombre: 'SRV-DC-01', tipo: 'SERVIDOR', cliente: 'Cliente B', ip: '10.0.0.5', so: 'Windows Server 2022', estado: 'Activo' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchResourceUsageReport(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<ResourceUsageReport> {
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
return {
|
||||
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
|
||||
dispositivos: [
|
||||
{ dispositivo: 'PC-Oficina-01', cliente: 'Cliente A', cpuPromedio: 24, memoriaPromedio: 62, redPromedioMB: 120 },
|
||||
{ dispositivo: 'LAPTOP-Ventas-02', cliente: 'Cliente A', cpuPromedio: 18, memoriaPromedio: 45, redPromedioMB: 85 },
|
||||
{ dispositivo: 'SRV-DC-01', cliente: 'Cliente B', cpuPromedio: 12, memoriaPromedio: 78, redPromedioMB: 340 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAlertsReport(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AlertsReport> {
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
return {
|
||||
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
|
||||
total: 28,
|
||||
items: [
|
||||
{ id: '1', fecha: '2024-01-15T10:30:00Z', titulo: 'CPU alto', severidad: 'CRITICAL', estado: 'RESUELTA', dispositivo: 'PC-Oficina-01', resueltoEn: '2024-01-15T10:45:00Z' },
|
||||
{ id: '2', fecha: '2024-01-15T09:00:00Z', titulo: 'Disco lleno', severidad: 'WARNING', estado: 'RESUELTA', dispositivo: 'SRV-DC-01', resueltoEn: '2024-01-15T11:00:00Z' },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ export const clientesRouter = router({
|
||||
dispositivosAlerta,
|
||||
alertasActivas,
|
||||
alertasCriticas,
|
||||
sesionesActivas,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.dispositivo.count({ where }),
|
||||
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
|
||||
@@ -173,6 +174,12 @@ export const clientesRouter = router({
|
||||
ctx.prisma.alerta.count({
|
||||
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
|
||||
}),
|
||||
ctx.prisma.sesionRemota.count({
|
||||
where: {
|
||||
finalizadaEn: null,
|
||||
...(clienteId ? { dispositivo: { clienteId } } : {}),
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -182,7 +189,7 @@ export const clientesRouter = router({
|
||||
dispositivosAlerta,
|
||||
alertasActivas,
|
||||
alertasCriticas,
|
||||
sesionesActivas: 0, // TODO: implementar
|
||||
sesionesActivas,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -4,7 +4,124 @@ import { TipoDispositivo } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { MeshCentralClient } from '@/server/services/meshcentral/client'
|
||||
|
||||
const tipoDispositivoSchema = z.enum([
|
||||
'PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH',
|
||||
'FIREWALL', 'AP', 'IMPRESORA', 'OTRO',
|
||||
])
|
||||
|
||||
const estadoDispositivoSchema = z.enum([
|
||||
'ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO',
|
||||
])
|
||||
|
||||
export const equiposRouter = router({
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
tipo: tipoDispositivoSchema,
|
||||
nombre: z.string().min(1, 'Nombre requerido'),
|
||||
descripcion: z.string().optional(),
|
||||
ubicacionId: z.string().optional().nullable(),
|
||||
estado: estadoDispositivoSchema.optional(),
|
||||
ip: z.string().optional().nullable(),
|
||||
mac: z.string().optional().nullable(),
|
||||
sistemaOperativo: z.string().optional().nullable(),
|
||||
versionSO: z.string().optional().nullable(),
|
||||
fabricante: z.string().optional().nullable(),
|
||||
modelo: z.string().optional().nullable(),
|
||||
serial: z.string().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId ?? input.clienteId
|
||||
if (!clienteId) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Se requiere cliente (seleccione un cliente o use un usuario con cliente asignado)',
|
||||
})
|
||||
}
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'No puede crear dispositivos para otro cliente' })
|
||||
}
|
||||
|
||||
return ctx.prisma.dispositivo.create({
|
||||
data: {
|
||||
clienteId,
|
||||
tipo: input.tipo as TipoDispositivo,
|
||||
nombre: input.nombre.trim(),
|
||||
descripcion: input.descripcion?.trim() || null,
|
||||
ubicacionId: input.ubicacionId || null,
|
||||
estado: (input.estado as 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO') ?? 'DESCONOCIDO',
|
||||
ip: input.ip?.trim() || null,
|
||||
mac: input.mac?.trim() || null,
|
||||
sistemaOperativo: input.sistemaOperativo?.trim() || null,
|
||||
versionSO: input.versionSO?.trim() || null,
|
||||
fabricante: input.fabricante?.trim() || null,
|
||||
modelo: input.modelo?.trim() || null,
|
||||
serial: input.serial?.trim() || null,
|
||||
},
|
||||
include: {
|
||||
cliente: { select: { id: true, nombre: true } },
|
||||
ubicacion: { select: { id: true, nombre: true } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tipo: tipoDispositivoSchema.optional(),
|
||||
nombre: z.string().min(1, 'Nombre requerido').optional(),
|
||||
descripcion: z.string().optional().nullable(),
|
||||
ubicacionId: z.string().optional().nullable(),
|
||||
estado: estadoDispositivoSchema.optional(),
|
||||
ip: z.string().optional().nullable(),
|
||||
mac: z.string().optional().nullable(),
|
||||
sistemaOperativo: z.string().optional().nullable(),
|
||||
versionSO: z.string().optional().nullable(),
|
||||
fabricante: z.string().optional().nullable(),
|
||||
modelo: z.string().optional().nullable(),
|
||||
serial: z.string().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.dispositivo.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { clienteId: true },
|
||||
})
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
|
||||
}
|
||||
if (ctx.user.clienteId && ctx.user.clienteId !== existing.clienteId) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'No puede editar este dispositivo' })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
if (input.tipo !== undefined) data.tipo = input.tipo as TipoDispositivo
|
||||
if (input.nombre !== undefined) data.nombre = input.nombre.trim()
|
||||
if (input.descripcion !== undefined) data.descripcion = input.descripcion?.trim() || null
|
||||
if (input.ubicacionId !== undefined) data.ubicacionId = input.ubicacionId || null
|
||||
if (input.estado !== undefined) data.estado = input.estado
|
||||
if (input.ip !== undefined) data.ip = input.ip?.trim() || null
|
||||
if (input.mac !== undefined) data.mac = input.mac?.trim() || null
|
||||
if (input.sistemaOperativo !== undefined) data.sistemaOperativo = input.sistemaOperativo?.trim() || null
|
||||
if (input.versionSO !== undefined) data.versionSO = input.versionSO?.trim() || null
|
||||
if (input.fabricante !== undefined) data.fabricante = input.fabricante?.trim() || null
|
||||
if (input.modelo !== undefined) data.modelo = input.modelo?.trim() || null
|
||||
if (input.serial !== undefined) data.serial = input.serial?.trim() || null
|
||||
|
||||
return ctx.prisma.dispositivo.update({
|
||||
where: { id: input.id },
|
||||
data,
|
||||
include: {
|
||||
cliente: { select: { id: true, nombre: true } },
|
||||
ubicacion: { select: { id: true, nombre: true } },
|
||||
software: { orderBy: { nombre: 'asc' }, take: 100 },
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
// Listar equipos de computo (PC, laptop, servidor)
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { equiposRouter } from './equipos.router'
|
||||
import { celularesRouter } from './celulares.router'
|
||||
import { redRouter } from './red.router'
|
||||
import { alertasRouter } from './alertas.router'
|
||||
import { sesionesRouter } from './sesiones.router'
|
||||
import { reportesRouter } from './reportes.router'
|
||||
import { usuariosRouter } from './usuarios.router'
|
||||
import { configuracionRouter } from './configuracion.router'
|
||||
@@ -16,6 +17,7 @@ export const appRouter = router({
|
||||
celulares: celularesRouter,
|
||||
red: redRouter,
|
||||
alertas: alertasRouter,
|
||||
sesiones: sesionesRouter,
|
||||
reportes: reportesRouter,
|
||||
usuarios: usuariosRouter,
|
||||
configuracion: configuracionRouter,
|
||||
|
||||
47
src/server/trpc/routers/sesiones.router.ts
Normal file
47
src/server/trpc/routers/sesiones.router.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure } from '../trpc'
|
||||
|
||||
export const sesionesRouter = router({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
limit: z.number().default(50),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { clienteId, limit = 50 } = input || {}
|
||||
|
||||
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
|
||||
const where = {
|
||||
finalizadaEn: null,
|
||||
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
|
||||
}
|
||||
|
||||
const sessions = await ctx.prisma.sesionRemota.findMany({
|
||||
where,
|
||||
include: {
|
||||
usuario: { select: { id: true, email: true, nombre: true } },
|
||||
dispositivo: { select: { id: true, nombre: true, clienteId: true } },
|
||||
},
|
||||
orderBy: { iniciadaEn: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
|
||||
return { sessions }
|
||||
}),
|
||||
|
||||
count: protectedProcedure
|
||||
.input(z.object({ clienteId: z.string().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input?.clienteId
|
||||
|
||||
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
|
||||
const where = {
|
||||
finalizadaEn: null,
|
||||
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
|
||||
}
|
||||
|
||||
return ctx.prisma.sesionRemota.count({ where })
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user