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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
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 { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
import { trpc } from '@/lib/trpc-client'
|
import { trpc } from '@/lib/trpc-client'
|
||||||
import { formatRelativeTime } from '@/lib/utils'
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
import DeviceCard, { type DeviceCardStatus } from '@/components/devices/DeviceCard'
|
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'
|
type StateFilter = '' | 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO'
|
||||||
|
|
||||||
@@ -61,6 +64,12 @@ export default function DevicesPage() {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [stateFilter, setStateFilter] = useState<StateFilter>('')
|
const [stateFilter, setStateFilter] = useState<StateFilter>('')
|
||||||
const [osFilter, setOsFilter] = useState('')
|
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(
|
const listQuery = trpc.equipos.list.useQuery(
|
||||||
{
|
{
|
||||||
@@ -86,23 +95,81 @@ export default function DevicesPage() {
|
|||||||
}))
|
}))
|
||||||
}, [listQuery.data])
|
}, [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) => {
|
const handleConnect = (id: string) => {
|
||||||
console.log('Conectar', id)
|
setConnectError(null)
|
||||||
|
setConnectingId(id)
|
||||||
|
iniciarSesionMutation.mutate({ dispositivoId: id, tipo: 'desktop' })
|
||||||
}
|
}
|
||||||
const handleFiles = (id: string) => {
|
const handleFiles = (id: string) => {
|
||||||
console.log('Archivos', id)
|
router.push(`/archivos?deviceId=${encodeURIComponent(id)}`)
|
||||||
}
|
}
|
||||||
const handleTerminal = (id: string) => {
|
const handleTerminal = (id: string) => {
|
||||||
console.log('Terminal', id)
|
router.push(`/terminal?deviceId=${encodeURIComponent(id)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header>
|
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">Dispositivos</h1>
|
<div>
|
||||||
<p className="mt-1 text-gray-400">Administración de equipos conectados</p>
|
<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>
|
</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="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
<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}
|
onConectar={handleConnect}
|
||||||
onArchivos={handleFiles}
|
onArchivos={handleFiles}
|
||||||
onTerminal={handleTerminal}
|
onTerminal={handleTerminal}
|
||||||
|
onInfo={(id, name) => openDetail(id, name ?? device.name)}
|
||||||
|
isConnecting={connectingId === device.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
73
src/app/(dashboard)/headwind/page.tsx
Normal file
73
src/app/(dashboard)/headwind/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
|
import HeadwindMetricCard from '@/components/headwind/HeadwindMetricCard'
|
||||||
|
import MobileDeviceList from '@/components/headwind/MobileDeviceList'
|
||||||
|
import CorporateAppsList from '@/components/headwind/CorporateAppsList'
|
||||||
|
import {
|
||||||
|
getMdmDashboardData,
|
||||||
|
type Device,
|
||||||
|
type AppDeployment,
|
||||||
|
type DashboardStats,
|
||||||
|
} from '@/mocks/mdmDashboardData'
|
||||||
|
|
||||||
|
export default function HeadwindPage() {
|
||||||
|
useSelectedClient()
|
||||||
|
|
||||||
|
const initial = useMemo(() => getMdmDashboardData(), [])
|
||||||
|
const [stats] = useState<DashboardStats>(initial.stats)
|
||||||
|
const [devices] = useState<Device[]>(initial.devices)
|
||||||
|
const [appDeployments] = useState<AppDeployment[]>(initial.appDeployments)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
Headwind MDM
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-gray-400">
|
||||||
|
Gestión de dispositivos móviles Android
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
|
||||||
|
aria-label="Resumen"
|
||||||
|
>
|
||||||
|
<HeadwindMetricCard
|
||||||
|
label="Dispositivos Android"
|
||||||
|
value={stats.totalAndroidDevices}
|
||||||
|
subtitle="Total registrados"
|
||||||
|
accent="green"
|
||||||
|
/>
|
||||||
|
<HeadwindMetricCard
|
||||||
|
label="Apps desplegadas"
|
||||||
|
value={stats.deployedApps}
|
||||||
|
subtitle="En catálogo"
|
||||||
|
accent="blue"
|
||||||
|
/>
|
||||||
|
<HeadwindMetricCard
|
||||||
|
label="Políticas activas"
|
||||||
|
value={stats.activePolicies}
|
||||||
|
subtitle="Configuraciones aplicadas"
|
||||||
|
accent="cyan"
|
||||||
|
/>
|
||||||
|
<HeadwindMetricCard
|
||||||
|
label="Batería promedio"
|
||||||
|
value={`${stats.averageBatteryPercent}%`}
|
||||||
|
subtitle="Estado actual"
|
||||||
|
accent="amber"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
|
||||||
|
aria-label="Dispositivos y apps"
|
||||||
|
>
|
||||||
|
<MobileDeviceList devices={devices} />
|
||||||
|
<CorporateAppsList apps={appDeployments} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -94,6 +94,12 @@ function DashboardContentInner({
|
|||||||
)
|
)
|
||||||
const devicesCount = devicesCountQuery.data?.pagination?.total ?? 0
|
const devicesCount = devicesCountQuery.data?.pagination?.total ?? 0
|
||||||
|
|
||||||
|
const sessionsCountQuery = trpc.sesiones.count.useQuery(
|
||||||
|
{ clienteId },
|
||||||
|
{ refetchOnWindowFocus: true, staleTime: 15 * 1000 }
|
||||||
|
)
|
||||||
|
const sessionsCount = sessionsCountQuery.data ?? 0
|
||||||
|
|
||||||
const clientsQuery = trpc.clientes.list.useQuery(
|
const clientsQuery = trpc.clientes.list.useQuery(
|
||||||
{ limit: 100 },
|
{ limit: 100 },
|
||||||
{ staleTime: 60 * 1000 }
|
{ staleTime: 60 * 1000 }
|
||||||
@@ -111,6 +117,7 @@ function DashboardContentInner({
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
activeAlertsCount={activeAlertsCount}
|
activeAlertsCount={activeAlertsCount}
|
||||||
devicesCount={devicesCount}
|
devicesCount={devicesCount}
|
||||||
|
sessionsCount={sessionsCount}
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
105
src/app/(dashboard)/librenms/page.tsx
Normal file
105
src/app/(dashboard)/librenms/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
|
import LibrenmsMetricCard from '@/components/librenms/LibrenmsMetricCard'
|
||||||
|
import DeviceList from '@/components/librenms/DeviceList'
|
||||||
|
import AlertList from '@/components/librenms/AlertList'
|
||||||
|
import type { NetworkDevice } from '@/components/librenms/DeviceRow'
|
||||||
|
import type { SnmpAlert } from '@/components/librenms/AlertItem'
|
||||||
|
|
||||||
|
const MOCK_DEVICES: NetworkDevice[] = [
|
||||||
|
{ id: '1', name: 'Core Switch', model: 'Cisco 3850', status: 'online' },
|
||||||
|
{ id: '2', name: 'Router Principal', model: 'MikroTik CCR', status: 'online' },
|
||||||
|
{ id: '3', name: 'AP-Oficina-01', model: 'Ubiquiti UAP', status: 'online' },
|
||||||
|
{ id: '4', name: 'Switch-Piso2', model: 'HP ProCurve', status: 'warning' },
|
||||||
|
{ id: '5', name: 'Firewall', model: 'pfSense', status: 'online' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MOCK_ALERTS: SnmpAlert[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'CPU Alto – Core Switch',
|
||||||
|
detail: 'Hace 15 min – 92% utilización',
|
||||||
|
severity: 'critical',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Puerto Down – Switch-Piso2',
|
||||||
|
detail: 'Hace 1h – GigabitEthernet0/12',
|
||||||
|
severity: 'warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Alto tráfico – WAN',
|
||||||
|
detail: 'Hace 2h – 95% capacidad',
|
||||||
|
severity: 'critical',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function LibrenmsPage() {
|
||||||
|
useSelectedClient()
|
||||||
|
|
||||||
|
const [devices] = useState<NetworkDevice[]>(MOCK_DEVICES)
|
||||||
|
const [alerts] = useState<SnmpAlert[]>(MOCK_ALERTS)
|
||||||
|
const [metrics] = useState({
|
||||||
|
totalDevices: 28,
|
||||||
|
uptime: 99.7,
|
||||||
|
activeAlerts: 3,
|
||||||
|
trafficToday: 847,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<header>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
LibreNMS - Monitoreo de Red
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-gray-400">
|
||||||
|
SNMP, NetFlow y alertas de infraestructura
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* KPI metric cards */}
|
||||||
|
<section
|
||||||
|
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
|
||||||
|
aria-label="Métricas principales"
|
||||||
|
>
|
||||||
|
<LibrenmsMetricCard
|
||||||
|
label="Dispositivos Red"
|
||||||
|
value={metrics.totalDevices}
|
||||||
|
subtitle="Switches, routers, APs"
|
||||||
|
accent="green"
|
||||||
|
/>
|
||||||
|
<LibrenmsMetricCard
|
||||||
|
label="Uptime Promedio"
|
||||||
|
value={`${metrics.uptime}%`}
|
||||||
|
subtitle="Últimos 30 días"
|
||||||
|
accent="cyan"
|
||||||
|
/>
|
||||||
|
<LibrenmsMetricCard
|
||||||
|
label="Alertas Activas"
|
||||||
|
value={metrics.activeAlerts}
|
||||||
|
subtitle="2 críticas, 1 warning"
|
||||||
|
accent="yellow"
|
||||||
|
/>
|
||||||
|
<LibrenmsMetricCard
|
||||||
|
label="Tráfico Total"
|
||||||
|
value={`${metrics.trafficToday} GB`}
|
||||||
|
subtitle="NetFlow hoy"
|
||||||
|
accent="blue"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Main content grid */}
|
||||||
|
<section
|
||||||
|
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
|
||||||
|
aria-label="Dispositivos y alertas"
|
||||||
|
>
|
||||||
|
<DeviceList devices={devices} />
|
||||||
|
<AlertList alerts={alerts} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,178 +1,29 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { RefreshCw, Grid, List, Filter } from 'lucide-react'
|
import Link from 'next/link'
|
||||||
import KPICards from '@/components/dashboard/KPICards'
|
import { Monitor, CheckCircle, XCircle, AlertTriangle, Plus } from 'lucide-react'
|
||||||
import DeviceGrid from '@/components/dashboard/DeviceGrid'
|
|
||||||
import AlertsFeed from '@/components/dashboard/AlertsFeed'
|
|
||||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { trpc } from '@/lib/trpc-client'
|
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 = {
|
const DEVICES_LIMIT = 8
|
||||||
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
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
|
||||||
const utils = trpc.useUtils()
|
|
||||||
const { selectedClientId } = useSelectedClient()
|
const { selectedClientId } = useSelectedClient()
|
||||||
const clienteId = selectedClientId ?? undefined
|
const clienteId = selectedClientId ?? undefined
|
||||||
|
|
||||||
@@ -180,38 +31,6 @@ export default function DashboardPage() {
|
|||||||
{ clienteId },
|
{ clienteId },
|
||||||
{ refetchOnWindowFocus: false }
|
{ 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(
|
const equiposQuery = trpc.equipos.list.useQuery(
|
||||||
{ page: 1, limit: DEVICES_LIMIT, clienteId },
|
{ page: 1, limit: DEVICES_LIMIT, clienteId },
|
||||||
@@ -226,147 +45,155 @@ export default function DashboardPage() {
|
|||||||
{ refetchOnWindowFocus: false }
|
{ refetchOnWindowFocus: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
const devices: DeviceForGrid[] = useMemo(() => {
|
const alertsQuery = trpc.alertas.list.useQuery(
|
||||||
const eq = equiposQuery.data?.dispositivos ?? []
|
{ page: 1, limit: 15, clienteId },
|
||||||
const rd = redQuery.data?.dispositivos ?? []
|
{ refetchOnWindowFocus: false }
|
||||||
const cel = celularesQuery.data?.dispositivos ?? []
|
)
|
||||||
const all = [...eq, ...rd, ...cel]
|
|
||||||
return all.map((d) => ({
|
const recentActivity: RecentActivityItem[] = useMemo(() => {
|
||||||
id: d.id,
|
const list = alertsQuery.data?.alertas ?? []
|
||||||
nombre: d.nombre,
|
return list.map((a) => ({
|
||||||
tipo: d.tipo,
|
id: a.id,
|
||||||
estado: d.estado,
|
type: 'alert' as const,
|
||||||
ip: d.ip ?? null,
|
description: a.titulo,
|
||||||
sistemaOperativo: d.sistemaOperativo ?? null,
|
deviceName: a.dispositivo?.nombre ?? '—',
|
||||||
lastSeen: d.lastSeen ?? null,
|
timestamp: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
|
||||||
cpuUsage: d.cpuUsage ?? null,
|
severity: a.severidad,
|
||||||
ramUsage: d.ramUsage ?? null,
|
|
||||||
cliente: d.cliente ? { nombre: d.cliente.nombre } : undefined,
|
|
||||||
}))
|
}))
|
||||||
}, [equiposQuery.data, redQuery.data, celularesQuery.data])
|
}, [alertsQuery.data])
|
||||||
|
|
||||||
const devicesLoading =
|
const stats = statsQuery.data ?? {
|
||||||
equiposQuery.isLoading || redQuery.isLoading || celularesQuery.isLoading
|
totalDispositivos: 0,
|
||||||
const isRefreshing =
|
dispositivosOnline: 0,
|
||||||
statsQuery.isFetching ||
|
dispositivosOffline: 0,
|
||||||
alertsQuery.isFetching ||
|
dispositivosAlerta: 0,
|
||||||
equiposQuery.isFetching ||
|
alertasActivas: 0,
|
||||||
redQuery.isFetching ||
|
alertasCriticas: 0,
|
||||||
celularesQuery.isFetching
|
sesionesActivas: 0,
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
await Promise.all([
|
|
||||||
statsQuery.refetch(),
|
|
||||||
alertsQuery.refetch(),
|
|
||||||
equiposQuery.refetch(),
|
|
||||||
redQuery.refetch(),
|
|
||||||
celularesQuery.refetch(),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeviceAction = (deviceId: string, action: string) => {
|
const deviceStatusBreakdown = {
|
||||||
console.log(`Action ${action} on device ${deviceId}`)
|
online: stats.dispositivosOnline,
|
||||||
// TODO: Implementar acciones
|
offline: stats.dispositivosOffline,
|
||||||
|
advertencia: stats.dispositivosAlerta,
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAcknowledgeAlert = (alertId: string) => {
|
const allDevices = [
|
||||||
acknowledgeMutation.mutate({ id: alertId })
|
...(equiposQuery.data?.dispositivos ?? []),
|
||||||
}
|
...(redQuery.data?.dispositivos ?? []),
|
||||||
|
...(celularesQuery.data?.dispositivos ?? []),
|
||||||
|
].slice(0, DEVICES_LIMIT)
|
||||||
|
|
||||||
const handleResolveAlert = (alertId: string) => {
|
const quickConnections: QuickConnectionItem[] =
|
||||||
resolveMutation.mutate({ id: alertId })
|
allDevices.length > 0
|
||||||
}
|
? allDevices.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.nombre,
|
||||||
|
status: mapEstadoToQuickStatus(d.estado),
|
||||||
|
}))
|
||||||
|
: MOCK_QUICK_CONNECTIONS
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
<h1 className="text-3xl font-bold text-white">MSP-CAS Dashboard</h1>
|
||||||
<p className="text-gray-500">Vision general del sistema</p>
|
<p className="mt-1 text-gray-400">
|
||||||
|
MeshCentral + LibreNMS + Headwind MDM unificados
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex shrink-0">
|
||||||
<button
|
<Link
|
||||||
onClick={handleRefresh}
|
href="/devices"
|
||||||
className="btn btn-secondary"
|
className="btn btn-primary inline-flex items-center gap-2"
|
||||||
disabled={isRefreshing}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn('w-4 h-4 mr-2', isRefreshing && 'animate-spin')} />
|
<Plus className="w-4 h-4" />
|
||||||
Actualizar
|
Agregar Dispositivo
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
<section
|
||||||
<KPICards stats={stats} />
|
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">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Devices */}
|
<section className="lg:col-span-2">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<DeviceStatusChart data={deviceStatusBreakdown} />
|
||||||
<div className="flex items-center justify-between">
|
</section>
|
||||||
<h2 className="text-lg font-medium">Dispositivos</h2>
|
<section className="min-h-0">
|
||||||
<div className="flex items-center gap-2">
|
<RecentActivityList items={recentActivity} isLoading={alertsQuery.isLoading} />
|
||||||
<button className="btn btn-ghost btn-sm">
|
</section>
|
||||||
<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>
|
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
</div>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { Monitor, FolderOpen, Terminal, Info, ExternalLink } from 'lucide-react'
|
import { Monitor, FolderOpen, Terminal, Info, ExternalLink } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -17,6 +16,8 @@ export interface DeviceCardProps {
|
|||||||
onConectar?: (id: string) => void
|
onConectar?: (id: string) => void
|
||||||
onArchivos?: (id: string) => void
|
onArchivos?: (id: string) => void
|
||||||
onTerminal?: (id: string) => void
|
onTerminal?: (id: string) => void
|
||||||
|
onInfo?: (id: string, name?: string) => void
|
||||||
|
isConnecting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig: Record<
|
const statusConfig: Record<
|
||||||
@@ -46,16 +47,23 @@ export default function DeviceCard({
|
|||||||
onConectar,
|
onConectar,
|
||||||
onArchivos,
|
onArchivos,
|
||||||
onTerminal,
|
onTerminal,
|
||||||
|
onInfo,
|
||||||
|
isConnecting = false,
|
||||||
}: DeviceCardProps) {
|
}: DeviceCardProps) {
|
||||||
const statusStyle = statusConfig[status]
|
const statusStyle = statusConfig[status]
|
||||||
const osLabel = normalizeOS(os)
|
const osLabel = normalizeOS(os)
|
||||||
const detailUrl = id ? `/devices/${id}` : '#'
|
const handleCardClick = () => id && onInfo?.(id, name)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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(
|
className={cn(
|
||||||
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5',
|
'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">
|
<div className="flex items-start gap-3">
|
||||||
@@ -89,14 +97,21 @@ export default function DeviceCard({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => id && onConectar?.(id)}
|
onClick={() => id && status === 'online' && !isConnecting && 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"
|
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" />
|
<ExternalLink className={cn('h-4 w-4', isConnecting && 'animate-pulse')} />
|
||||||
<span className="text-xs font-medium">Conectar</span>
|
<span className="text-xs font-medium">{isConnecting ? 'Conectando…' : 'Conectar'}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -114,13 +129,14 @@ export default function DeviceCard({
|
|||||||
<Terminal className="h-4 w-4" />
|
<Terminal className="h-4 w-4" />
|
||||||
<span className="text-xs font-medium">Terminal</span>
|
<span className="text-xs font-medium">Terminal</span>
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<button
|
||||||
href={detailUrl}
|
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"
|
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" />
|
<Info className="h-4 w-4" />
|
||||||
<span className="text-xs font-medium">Info</span>
|
<span className="text-xs font-medium">Info</span>
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'LibreNMS',
|
label: 'LibreNMS',
|
||||||
href: '/configuracion',
|
href: '/librenms',
|
||||||
icon: <Network className="w-5 h-5" />,
|
icon: <Network className="w-5 h-5" />,
|
||||||
badge: { type: 'green', value: 'OK' },
|
badge: { type: 'green', value: 'OK' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Headwind MDM',
|
label: 'Headwind MDM',
|
||||||
href: '/configuracion',
|
href: '/headwind',
|
||||||
icon: <Smartphone className="w-5 h-5" />,
|
icon: <Smartphone className="w-5 h-5" />,
|
||||||
badge: { type: 'blue', value: 12 },
|
badge: { type: 'blue', value: 12 },
|
||||||
},
|
},
|
||||||
@@ -102,11 +102,12 @@ const menuConfig: SidebarMenuSection[] = [
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeAlertsCount?: number
|
activeAlertsCount?: number
|
||||||
devicesCount?: number
|
devicesCount?: number
|
||||||
|
sessionsCount?: number
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({ activeAlertsCount, devicesCount, open = false, onClose }: SidebarProps) {
|
export default function Sidebar({ activeAlertsCount, devicesCount, sessionsCount, open = false, onClose }: SidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
@@ -122,6 +123,10 @@ export default function Sidebar({ activeAlertsCount, devicesCount, open = false,
|
|||||||
if (item.href === '/devices' && devicesCount !== undefined) {
|
if (item.href === '/devices' && devicesCount !== undefined) {
|
||||||
return { type: 'red', value: devicesCount }
|
return { type: 'red', value: devicesCount }
|
||||||
}
|
}
|
||||||
|
if (item.href === '/sesiones' && sessionsCount !== undefined) {
|
||||||
|
if (sessionsCount === 0) return undefined
|
||||||
|
return { type: 'red', value: sessionsCount }
|
||||||
|
}
|
||||||
return item.badge
|
return item.badge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
src/components/librenms/AlertItem.tsx
Normal file
33
src/components/librenms/AlertItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type AlertSeverity = 'critical' | 'warning' | 'info'
|
||||||
|
|
||||||
|
export interface SnmpAlert {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
severity: AlertSeverity
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityStyles: Record<AlertSeverity, string> = {
|
||||||
|
critical: 'text-red-400',
|
||||||
|
warning: 'text-yellow-400',
|
||||||
|
info: 'text-blue-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertItemProps {
|
||||||
|
alert: SnmpAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertItem({ alert }: AlertItemProps) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-white/5 py-4 last:border-b-0">
|
||||||
|
<p className={cn('font-medium', severityStyles[alert.severity])}>
|
||||||
|
{alert.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{alert.detail}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/components/librenms/AlertList.tsx
Normal file
31
src/components/librenms/AlertList.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { SnmpAlert } from './AlertItem'
|
||||||
|
import AlertItem from './AlertItem'
|
||||||
|
|
||||||
|
interface AlertListProps {
|
||||||
|
alerts: SnmpAlert[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertList({ alerts }: AlertListProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||||
|
<div className="border-b border-white/10 px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400">
|
||||||
|
Alertas SNMP Recientes
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="px-4">
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-gray-500">
|
||||||
|
Sin alertas
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
alerts.map((alert) => (
|
||||||
|
<AlertItem key={alert.id} alert={alert} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/components/librenms/DeviceList.tsx
Normal file
33
src/components/librenms/DeviceList.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { NetworkDevice } from './DeviceRow'
|
||||||
|
import DeviceRow from './DeviceRow'
|
||||||
|
|
||||||
|
interface DeviceListProps {
|
||||||
|
devices: NetworkDevice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceList({ devices }: DeviceListProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||||
|
<div className="border-b border-white/10 px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400">
|
||||||
|
Dispositivos de Red
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/5">
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-gray-500">
|
||||||
|
Sin dispositivos
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
devices.map((device) => (
|
||||||
|
<div key={device.id} className="px-4">
|
||||||
|
<DeviceRow device={device} />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/components/librenms/DeviceRow.tsx
Normal file
37
src/components/librenms/DeviceRow.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { DeviceStatus } from './StatusBadge'
|
||||||
|
import StatusBadge from './StatusBadge'
|
||||||
|
|
||||||
|
export interface NetworkDevice {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
model?: string
|
||||||
|
status: DeviceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<DeviceStatus, string> = {
|
||||||
|
online: 'Online',
|
||||||
|
warning: 'Warning',
|
||||||
|
critical: 'Critical',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceRowProps {
|
||||||
|
device: NetworkDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceRow({ device }: DeviceRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium text-gray-200">{device.name}</span>
|
||||||
|
{device.model && (
|
||||||
|
<span className="ml-2 text-sm text-gray-500">– {device.model}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={device.status} label={statusLabel[device.status]} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/components/librenms/LibrenmsMetricCard.tsx
Normal file
45
src/components/librenms/LibrenmsMetricCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type AccentColor = 'green' | 'cyan' | 'yellow' | 'blue'
|
||||||
|
|
||||||
|
interface LibrenmsMetricCardProps {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
subtitle: string
|
||||||
|
accent?: AccentColor
|
||||||
|
}
|
||||||
|
|
||||||
|
const accentColors: Record<AccentColor, string> = {
|
||||||
|
green: 'text-emerald-400',
|
||||||
|
cyan: 'text-cyan-400',
|
||||||
|
yellow: 'text-amber-400',
|
||||||
|
blue: 'text-blue-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LibrenmsMetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
accent = 'cyan',
|
||||||
|
}: LibrenmsMetricCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-white/10 overflow-hidden',
|
||||||
|
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||||
|
'flex flex-col justify-center px-5 py-6',
|
||||||
|
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/components/librenms/LibrenmsRow.tsx
Normal file
49
src/components/librenms/LibrenmsRow.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface LibrenmsNodeItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
ip: string
|
||||||
|
type: string
|
||||||
|
status: 'up' | 'down' | 'warning'
|
||||||
|
lastUpdate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibrenmsRowProps {
|
||||||
|
item: LibrenmsNodeItem
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
up: { label: 'En línea', dot: 'bg-emerald-500' },
|
||||||
|
down: { label: 'Fuera de línea', dot: 'bg-red-500' },
|
||||||
|
warning: { label: 'Alerta', dot: 'bg-amber-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LibrenmsRow({ item }: LibrenmsRowProps) {
|
||||||
|
const status = statusConfig[item.status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5 cursor-pointer">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className="font-medium text-gray-200">{item.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-sm text-gray-400">
|
||||||
|
{item.ip}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className="rounded-full bg-cyan-500/10 px-3 py-1 text-xs text-cyan-400">
|
||||||
|
{item.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('h-2 w-2 rounded-full', status.dot)} />
|
||||||
|
<span className="text-sm text-gray-400">{status.label}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/components/librenms/StatusBadge.tsx
Normal file
26
src/components/librenms/StatusBadge.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type DeviceStatus = 'online' | 'warning' | 'critical'
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: DeviceStatus
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<DeviceStatus, { dot: string; text: string }> = {
|
||||||
|
online: { dot: 'bg-green-500', text: 'text-green-400' },
|
||||||
|
warning: { dot: 'bg-yellow-500', text: 'text-yellow-400' },
|
||||||
|
critical: { dot: 'bg-red-500', text: 'text-red-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status, label }: StatusBadgeProps) {
|
||||||
|
const config = statusConfig[status]
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', config.text)}>
|
||||||
|
<span className={cn('h-2 w-2 shrink-0 rounded-full', config.dot)} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/components/performance/LineChart.tsx
Normal file
57
src/components/performance/LineChart.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface LineChartProps {
|
||||||
|
className?: string
|
||||||
|
height?: number
|
||||||
|
data?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LineChart({
|
||||||
|
className,
|
||||||
|
height = 160,
|
||||||
|
data = [],
|
||||||
|
}: LineChartProps) {
|
||||||
|
const points = data.length >= 2
|
||||||
|
? data
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = (i / Math.max(1, data.length - 1)) * 100
|
||||||
|
const y = 100 - Math.min(100, Math.max(0, v))
|
||||||
|
return `${x},${y}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
: '0,100 100,100'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg bg-dark-200/80 border border-white/5 overflow-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
{data.length >= 2 ? (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="w-full h-full text-cyan-500/30"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
points={points}
|
||||||
|
className="transition-all duration-500"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-gray-600 text-sm">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
src/components/performance/MetricCard.tsx
Normal file
72
src/components/performance/MetricCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import LineChart from './LineChart'
|
||||||
|
|
||||||
|
export interface MetricCardFooterRow {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
valueSuffix?: string
|
||||||
|
footerStats: MetricCardFooterRow[]
|
||||||
|
chartData?: number[]
|
||||||
|
highUsage?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
valueSuffix,
|
||||||
|
footerStats,
|
||||||
|
chartData,
|
||||||
|
highUsage = false,
|
||||||
|
}: MetricCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col rounded-xl border border-white/10 overflow-hidden',
|
||||||
|
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||||
|
'transition-all duration-200 hover:border-white/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center justify-between px-4 py-3 border-b border-white/5">
|
||||||
|
<span className="text-sm font-medium text-gray-400">{title}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-lg font-semibold tabular-nums transition-colors duration-300',
|
||||||
|
highUsage ? 'text-red-400' : 'text-cyan-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
{valueSuffix != null && (
|
||||||
|
<span className="text-sm font-normal text-gray-500 ml-0.5">
|
||||||
|
{valueSuffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 px-4 pt-3">
|
||||||
|
<LineChart height={150} data={chartData ?? []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 space-y-1.5 px-4 py-3 border-t border-white/5">
|
||||||
|
{footerStats.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.label}
|
||||||
|
className="flex justify-between text-xs"
|
||||||
|
>
|
||||||
|
<span className="text-gray-500">{row.label}</span>
|
||||||
|
<span className="font-mono text-gray-400 tabular-nums">
|
||||||
|
{row.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/performance/ProcessRow.tsx
Normal file
36
src/components/performance/ProcessRow.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface ProcessItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
pid: number
|
||||||
|
cpu: number
|
||||||
|
memory: string
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessRowProps {
|
||||||
|
process: ProcessItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProcessRow({ process }: ProcessRowProps) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
|
||||||
|
<td className="py-2.5 px-4 text-sm text-gray-200 truncate max-w-[200px]">
|
||||||
|
{process.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-4 font-mono text-sm text-gray-400 tabular-nums">
|
||||||
|
{process.pid}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-4 font-mono text-sm text-cyan-400 tabular-nums">
|
||||||
|
{process.cpu}%
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-4 font-mono text-sm text-gray-400">
|
||||||
|
{process.memory}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-4 text-sm text-gray-500">{process.state}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/components/performance/ProcessTable.tsx
Normal file
65
src/components/performance/ProcessTable.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ProcessItem } from './ProcessRow'
|
||||||
|
import ProcessRow from './ProcessRow'
|
||||||
|
|
||||||
|
interface ProcessTableProps {
|
||||||
|
processes: ProcessItem[]
|
||||||
|
noDevice?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProcessTable({ processes, noDevice }: ProcessTableProps) {
|
||||||
|
if (noDevice) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-white/10">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400">
|
||||||
|
Procesos Activos (Top 10 por CPU)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||||
|
Selecciona un dispositivo
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-white/10">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400">
|
||||||
|
Procesos Activos (Top 10 por CPU)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||||
|
<th className="py-3 px-4">Proceso</th>
|
||||||
|
<th className="py-3 px-4 w-20">PID</th>
|
||||||
|
<th className="py-3 px-4 w-24">CPU %</th>
|
||||||
|
<th className="py-3 px-4 w-24">Memoria</th>
|
||||||
|
<th className="py-3 px-4 w-24">Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{processes.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
className="py-8 text-center text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
Sin datos
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
processes.map((p) => (
|
||||||
|
<ProcessRow key={p.id} process={p} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/components/reportes/DateRangeFilter.tsx
Normal file
60
src/components/reportes/DateRangeFilter.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export interface DateRangeValue {
|
||||||
|
desde: string
|
||||||
|
hasta: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DateRangeFilterProps {
|
||||||
|
value: DateRangeValue
|
||||||
|
onChange: (value: DateRangeValue) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DateRangeFilter({ value, onChange }: DateRangeFilterProps) {
|
||||||
|
const handleDesdeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const desde = e.target.value
|
||||||
|
const hasta = value.hasta && desde > value.hasta ? desde : value.hasta
|
||||||
|
onChange({ desde, hasta })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHastaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const hasta = e.target.value
|
||||||
|
onChange({ ...value, hasta })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHastaBeforeDesde =
|
||||||
|
value.desde && value.hasta && value.hasta < value.desde
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||||
|
Desde
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value.desde}
|
||||||
|
onChange={handleDesdeChange}
|
||||||
|
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||||
|
Hasta
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value.hasta}
|
||||||
|
onChange={handleHastaChange}
|
||||||
|
min={value.desde || undefined}
|
||||||
|
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{isHastaBeforeDesde && (
|
||||||
|
<p className="text-sm text-amber-400" role="alert">
|
||||||
|
La fecha Hasta no puede ser anterior a Desde
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
src/components/reportes/ReportCard.tsx
Normal file
62
src/components/reportes/ReportCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { FileText, FileSpreadsheet } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ReportCardProps {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
onExportPdf: () => void
|
||||||
|
onExportExcel: () => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onExportPdf,
|
||||||
|
onExportExcel,
|
||||||
|
loading = false,
|
||||||
|
}: ReportCardProps) {
|
||||||
|
const btnBase =
|
||||||
|
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
const btnPrimary =
|
||||||
|
'bg-cyan-600 text-white hover:bg-cyan-500'
|
||||||
|
const btnOutlined =
|
||||||
|
'border border-white/10 text-gray-300 hover:bg-white/5 hover:text-gray-200'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-white/10 overflow-hidden',
|
||||||
|
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||||
|
'flex flex-col'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="border-b border-white/10 px-4 py-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-400">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExportPdf}
|
||||||
|
disabled={loading}
|
||||||
|
className={cn(btnBase, btnPrimary)}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Exportar PDF
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExportExcel}
|
||||||
|
disabled={loading}
|
||||||
|
className={cn(btnBase, btnOutlined)}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
|
Excel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
src/components/sessions/SessionCard.tsx
Normal file
95
src/components/sessions/SessionCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Monitor, Terminal, FolderOpen } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type SessionTypeLabel = 'Escritorio Remoto' | 'Terminal' | 'Archivos'
|
||||||
|
|
||||||
|
export interface SessionCardProps {
|
||||||
|
id: string
|
||||||
|
deviceName: string
|
||||||
|
userEmail: string
|
||||||
|
sessionType: SessionTypeLabel
|
||||||
|
duration: string
|
||||||
|
onEnd?: (id: string) => void
|
||||||
|
isEnding?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig: Record<
|
||||||
|
SessionTypeLabel,
|
||||||
|
{ icon: React.ReactNode; bgClass: string }
|
||||||
|
> = {
|
||||||
|
'Escritorio Remoto': {
|
||||||
|
icon: <Monitor className="h-5 w-5" />,
|
||||||
|
bgClass: 'bg-cyan-500/15 text-cyan-400',
|
||||||
|
},
|
||||||
|
Terminal: {
|
||||||
|
icon: <Terminal className="h-5 w-5" />,
|
||||||
|
bgClass: 'bg-emerald-500/15 text-emerald-400',
|
||||||
|
},
|
||||||
|
Archivos: {
|
||||||
|
icon: <FolderOpen className="h-5 w-5" />,
|
||||||
|
bgClass: 'bg-amber-500/15 text-amber-400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionCard({
|
||||||
|
id,
|
||||||
|
deviceName,
|
||||||
|
userEmail,
|
||||||
|
sessionType,
|
||||||
|
duration,
|
||||||
|
onEnd,
|
||||||
|
isEnding,
|
||||||
|
}: SessionCardProps) {
|
||||||
|
const config = typeConfig[sessionType]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-4 rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5 transition-all duration-200',
|
||||||
|
'sm:flex-row sm:items-center sm:justify-between',
|
||||||
|
'hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
|
||||||
|
config.bgClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{config.icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-lg font-semibold text-gray-100 truncate">
|
||||||
|
{deviceName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 truncate">{userEmail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||||
|
<span className="font-mono text-sm font-medium text-cyan-400 tabular-nums">
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">{sessionType}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 sm:pl-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEnd?.(id)}
|
||||||
|
disabled={isEnding}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-red-500/60 px-4 py-2 text-sm font-medium text-red-400',
|
||||||
|
'transition-colors hover:bg-red-500/15 hover:border-red-500/80',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isEnding ? 'Terminando…' : 'Terminar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/components/software/SoftwareRow.tsx
Normal file
48
src/components/software/SoftwareRow.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface SoftwareItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
vendor: string
|
||||||
|
installations: number
|
||||||
|
lastUpdate: string
|
||||||
|
licensed?: boolean
|
||||||
|
needsUpdate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SoftwareRowProps {
|
||||||
|
item: SoftwareItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SoftwareRow({ item }: SoftwareRowProps) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-200">{item.name}</span>
|
||||||
|
{item.licensed && (
|
||||||
|
<span className="rounded bg-cyan-500/20 px-1.5 py-0.5 text-xs text-cyan-400">
|
||||||
|
Licencia
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.needsUpdate && (
|
||||||
|
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-400">
|
||||||
|
Actualizar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-sm text-gray-400">
|
||||||
|
{item.version}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-400">{item.vendor}</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-sm text-cyan-400 tabular-nums">
|
||||||
|
{item.installations}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/components/software/SoftwareTable.tsx
Normal file
42
src/components/software/SoftwareTable.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { SoftwareItem } from './SoftwareRow'
|
||||||
|
import SoftwareRow from './SoftwareRow'
|
||||||
|
|
||||||
|
interface SoftwareTableProps {
|
||||||
|
items: SoftwareItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SoftwareTable({ items }: SoftwareTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wide text-gray-400">
|
||||||
|
<th className="py-3 px-4">Nombre</th>
|
||||||
|
<th className="py-3 px-4 w-40">Versión</th>
|
||||||
|
<th className="py-3 px-4 w-48">Editor</th>
|
||||||
|
<th className="py-3 px-4 w-28">Instalaciones</th>
|
||||||
|
<th className="py-3 px-4 w-36">Última actualización</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
className="py-12 text-center text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
No hay resultados
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<SoftwareRow key={item.id} item={item} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/components/software/SummaryMetricCard.tsx
Normal file
31
src/components/software/SummaryMetricCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SummaryMetricCardProps {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SummaryMetricCard({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
}: SummaryMetricCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||||
|
'flex flex-col items-center justify-center py-6 px-4',
|
||||||
|
'transition-all duration-200 hover:border-white/20',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-4xl font-bold text-cyan-400 tabular-nums">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 text-sm text-gray-400">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/terminal/QuickCommands.tsx
Normal file
36
src/components/terminal/QuickCommands.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export interface QuickCommandsProps {
|
||||||
|
onSelectCommand: (command: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMANDS = [
|
||||||
|
'systeminfo',
|
||||||
|
'ipconfig',
|
||||||
|
'tasklist',
|
||||||
|
'netstat',
|
||||||
|
'CPU Info',
|
||||||
|
'RAM Info',
|
||||||
|
'dir C:\\',
|
||||||
|
'hostname',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function QuickCommands({ onSelectCommand }: QuickCommandsProps) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400">Comandos Rápidos</h3>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
|
||||||
|
{COMMANDS.map((cmd) => (
|
||||||
|
<button
|
||||||
|
key={cmd}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectCommand(cmd)}
|
||||||
|
className="shrink-0 rounded-lg border border-white/10 bg-dark-300/80 px-4 py-2 font-mono text-xs text-gray-300 transition-colors hover:border-cyan-500/30 hover:bg-dark-200 hover:text-cyan-400"
|
||||||
|
>
|
||||||
|
{cmd}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
src/components/terminal/TerminalWindow.tsx
Normal file
153
src/components/terminal/TerminalWindow.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef, useEffect } from 'react'
|
||||||
|
import { Send, Copy } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface TerminalWindowProps {
|
||||||
|
connectedDeviceName: string | null
|
||||||
|
outputLines: string[]
|
||||||
|
command: string
|
||||||
|
onCommandChange: (value: string) => void
|
||||||
|
onSendCommand: () => void
|
||||||
|
onClear: () => void
|
||||||
|
onCopy: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASCII_PLACEHOLDER = `
|
||||||
|
__ __ _ ____ _ _
|
||||||
|
| \\/ | ___ ___| |__ / ___|___ _ __ | |_ _ __ __ _| |
|
||||||
|
| |\\/| |/ _ \\/ __| '_ \\| | / _ \\ '_ \\| __| '__/ _\` | |
|
||||||
|
| | | | __/\\__ \\ | | | |__| __/ | | | |_| | | (_| | |
|
||||||
|
|_| |_|\\___||___/_| |_|\\____\\___|_| |_|\\__|_| \\__,_|_|
|
||||||
|
`
|
||||||
|
|
||||||
|
export default function TerminalWindow({
|
||||||
|
connectedDeviceName,
|
||||||
|
outputLines,
|
||||||
|
command,
|
||||||
|
onCommandChange,
|
||||||
|
onSendCommand,
|
||||||
|
onClear,
|
||||||
|
onCopy,
|
||||||
|
disabled = false,
|
||||||
|
}: TerminalWindowProps) {
|
||||||
|
const outputEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
outputEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [outputLines])
|
||||||
|
|
||||||
|
const isEmpty = outputLines.length === 0
|
||||||
|
const showPlaceholder = !connectedDeviceName
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
onSendCommand()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-[520px] min-h-[320px] flex-col overflow-hidden rounded-xl',
|
||||||
|
'border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||||
|
'shadow-lg'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-white/10 bg-dark-200/80 px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-red-500/90" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-amber-500/90" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-emerald-500/90" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-400">
|
||||||
|
bash — {connectedDeviceName ?? 'No conectado'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Limpiar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCopy}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
Copiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 font-mono text-sm">
|
||||||
|
{showPlaceholder ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
|
<pre className="whitespace-pre text-cyan-400/80">{ASCII_PLACEHOLDER}</pre>
|
||||||
|
<p className="mt-6 text-gray-500">
|
||||||
|
Selecciona un dispositivo para iniciar una sesión de terminal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : isEmpty ? (
|
||||||
|
<div className="flex h-full flex-col justify-end">
|
||||||
|
<div ref={outputEndRef} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{outputLines.map((line, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
line.startsWith('$ ')
|
||||||
|
? 'text-cyan-400'
|
||||||
|
: line.startsWith('> ')
|
||||||
|
? 'text-gray-500'
|
||||||
|
: 'text-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={outputEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t border-white/10 bg-dark-200/60 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="shrink-0 font-mono text-cyan-400">$</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => onCommandChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Escribe un comando..."
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 bg-transparent font-mono text-gray-200 placeholder-gray-500',
|
||||||
|
'focus:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSendCommand}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center justify-center rounded-lg bg-cyan-600 px-4 py-2',
|
||||||
|
'text-white transition-all hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,6 +24,14 @@ export function formatUptime(seconds: number): string {
|
|||||||
return `${minutes}m`
|
return `${minutes}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDurationSeconds(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||||
|
return `${pad(h)}:${pad(m)}:${pad(s)}`
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(date: Date | string): string {
|
export function formatDate(date: Date | string): string {
|
||||||
const d = new Date(date)
|
const d = new Date(date)
|
||||||
return d.toLocaleDateString('es-MX', {
|
return d.toLocaleDateString('es-MX', {
|
||||||
|
|||||||
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,
|
dispositivosAlerta,
|
||||||
alertasActivas,
|
alertasActivas,
|
||||||
alertasCriticas,
|
alertasCriticas,
|
||||||
|
sesionesActivas,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
ctx.prisma.dispositivo.count({ where }),
|
ctx.prisma.dispositivo.count({ where }),
|
||||||
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
|
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
|
||||||
@@ -173,6 +174,12 @@ export const clientesRouter = router({
|
|||||||
ctx.prisma.alerta.count({
|
ctx.prisma.alerta.count({
|
||||||
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
|
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
|
||||||
}),
|
}),
|
||||||
|
ctx.prisma.sesionRemota.count({
|
||||||
|
where: {
|
||||||
|
finalizadaEn: null,
|
||||||
|
...(clienteId ? { dispositivo: { clienteId } } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -182,7 +189,7 @@ export const clientesRouter = router({
|
|||||||
dispositivosAlerta,
|
dispositivosAlerta,
|
||||||
alertasActivas,
|
alertasActivas,
|
||||||
alertasCriticas,
|
alertasCriticas,
|
||||||
sesionesActivas: 0, // TODO: implementar
|
sesionesActivas,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,124 @@ import { TipoDispositivo } from '@prisma/client'
|
|||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { MeshCentralClient } from '@/server/services/meshcentral/client'
|
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({
|
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)
|
// Listar equipos de computo (PC, laptop, servidor)
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { equiposRouter } from './equipos.router'
|
|||||||
import { celularesRouter } from './celulares.router'
|
import { celularesRouter } from './celulares.router'
|
||||||
import { redRouter } from './red.router'
|
import { redRouter } from './red.router'
|
||||||
import { alertasRouter } from './alertas.router'
|
import { alertasRouter } from './alertas.router'
|
||||||
|
import { sesionesRouter } from './sesiones.router'
|
||||||
import { reportesRouter } from './reportes.router'
|
import { reportesRouter } from './reportes.router'
|
||||||
import { usuariosRouter } from './usuarios.router'
|
import { usuariosRouter } from './usuarios.router'
|
||||||
import { configuracionRouter } from './configuracion.router'
|
import { configuracionRouter } from './configuracion.router'
|
||||||
@@ -16,6 +17,7 @@ export const appRouter = router({
|
|||||||
celulares: celularesRouter,
|
celulares: celularesRouter,
|
||||||
red: redRouter,
|
red: redRouter,
|
||||||
alertas: alertasRouter,
|
alertas: alertasRouter,
|
||||||
|
sesiones: sesionesRouter,
|
||||||
reportes: reportesRouter,
|
reportes: reportesRouter,
|
||||||
usuarios: usuariosRouter,
|
usuarios: usuariosRouter,
|
||||||
configuracion: configuracionRouter,
|
configuracion: configuracionRouter,
|
||||||
|
|||||||
47
src/server/trpc/routers/sesiones.router.ts
Normal file
47
src/server/trpc/routers/sesiones.router.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { router, protectedProcedure } from '../trpc'
|
||||||
|
|
||||||
|
export const sesionesRouter = router({
|
||||||
|
list: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
clienteId: z.string().optional(),
|
||||||
|
limit: z.number().default(50),
|
||||||
|
}).optional()
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { clienteId, limit = 50 } = input || {}
|
||||||
|
|
||||||
|
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
|
||||||
|
const where = {
|
||||||
|
finalizadaEn: null,
|
||||||
|
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await ctx.prisma.sesionRemota.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
usuario: { select: { id: true, email: true, nombre: true } },
|
||||||
|
dispositivo: { select: { id: true, nombre: true, clienteId: true } },
|
||||||
|
},
|
||||||
|
orderBy: { iniciadaEn: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { sessions }
|
||||||
|
}),
|
||||||
|
|
||||||
|
count: protectedProcedure
|
||||||
|
.input(z.object({ clienteId: z.string().optional() }).optional())
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const clienteId = ctx.user.clienteId || input?.clienteId
|
||||||
|
|
||||||
|
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
|
||||||
|
const where = {
|
||||||
|
finalizadaEn: null,
|
||||||
|
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.prisma.sesionRemota.count({ where })
|
||||||
|
}),
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user