Compare commits

..

11 Commits

81 changed files with 9701 additions and 479 deletions

View File

@@ -12,6 +12,9 @@ RUN npm ci
# Copy source code
COPY . .
# Ensure public exists (Next.js may not have one; COPY in runner stage requires it)
RUN mkdir -p public
# Generate Prisma client
RUN npx prisma generate

3764
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
},
"dependencies": {
"@prisma/client": "^5.10.0",
"@tanstack/react-query": "^5.24.0",
"@tanstack/react-query": "^4.36.0",
"@trpc/client": "^10.45.0",
"@trpc/next": "^10.45.0",
"@trpc/react-query": "^10.45.0",

0
scripts/setup.sh Normal file → Executable file
View File

View File

@@ -0,0 +1,61 @@
'use client'
import { useMemo } from 'react'
import AlertsSection from '@/components/alerts/AlertsSection'
import type { AlertCardData } from '@/components/alerts/AlertCard'
import { trpc } from '@/lib/trpc-client'
export default function AlertsPage() {
const utils = trpc.useUtils()
const alertsQuery = trpc.alertas.list.useQuery(
{ page: 1, limit: 100 },
{ refetchOnWindowFocus: false }
)
const acknowledgeMutation = trpc.alertas.reconocer.useMutation({
onSuccess: () => {
utils.alertas.list.invalidate()
utils.alertas.conteoActivas.invalidate()
utils.clientes.dashboardStats.invalidate()
},
})
const resolveMutation = trpc.alertas.resolver.useMutation({
onSuccess: () => {
utils.alertas.list.invalidate()
utils.alertas.conteoActivas.invalidate()
utils.clientes.dashboardStats.invalidate()
},
})
const alerts: AlertCardData[] = useMemo(() => {
const list = alertsQuery.data?.alertas ?? []
return list.map((a) => ({
id: a.id,
title: a.titulo,
device: a.dispositivo?.nombre ?? '—',
description: a.mensaje,
severity: a.severidad,
timestamp: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
status: a.estado,
}))
}, [alertsQuery.data])
const handleAcknowledge = (id: string) => {
acknowledgeMutation.mutate({ id })
}
const handleResolve = (id: string) => {
resolveMutation.mutate({ id })
}
return (
<div className="min-h-full">
<AlertsSection
alerts={alerts}
isLoading={alertsQuery.isLoading}
onAcknowledge={handleAcknowledge}
onResolve={handleResolve}
/>
</div>
)
}

View 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>
)
}

View File

@@ -0,0 +1,244 @@
'use client'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { Search, Plus } from 'lucide-react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { trpc } from '@/lib/trpc-client'
import { formatRelativeTime } from '@/lib/utils'
import DeviceCard, { type DeviceCardStatus } from '@/components/devices/DeviceCard'
import AddDeviceModal from '@/components/devices/AddDeviceModal'
import DeviceDetailModal from '@/components/devices/device-detail/DeviceDetailModal'
type StateFilter = '' | 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO'
const STATE_OPTIONS: { value: StateFilter; label: string }[] = [
{ value: '', label: 'Todos los estados' },
{ value: 'ONLINE', label: 'En línea' },
{ value: 'OFFLINE', label: 'Fuera de línea' },
{ value: 'ALERTA', label: 'Advertencia' },
{ value: 'MANTENIMIENTO', label: 'Mantenimiento' },
{ value: 'DESCONOCIDO', label: 'Desconocido' },
]
const OS_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: 'Todos los SO' },
{ value: 'Windows', label: 'Windows' },
{ value: 'Linux', label: 'Linux' },
{ value: 'Ubuntu', label: 'Ubuntu' },
{ value: 'macOS', label: 'macOS' },
]
function mapStateToCardStatus(state: string): DeviceCardStatus {
switch (state) {
case 'ONLINE':
return 'online'
case 'OFFLINE':
case 'DESCONOCIDO':
return 'offline'
case 'ALERTA':
case 'MANTENIMIENTO':
return 'warning'
default:
return 'offline'
}
}
function formatLastSeenLabel(state: string, lastSeen: Date | string | null): string {
if (state === 'ONLINE') return 'En línea'
if (!lastSeen) return '—'
const d = new Date(lastSeen)
const now = new Date()
const diffMin = (now.getTime() - d.getTime()) / 60000
if (diffMin < 1) return 'Hace un momento'
if (diffMin < 60) return `Hace ${Math.floor(diffMin)} min`
const hours = Math.floor(diffMin / 60)
if (hours < 24) return `Hace ${hours} h`
return formatRelativeTime(lastSeen)
}
export default function DevicesPage() {
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState<StateFilter>('')
const [osFilter, setOsFilter] = useState('')
const [addModalOpen, setAddModalOpen] = useState(false)
const [detailDeviceId, setDetailDeviceId] = useState<string | null>(null)
const [detailDeviceName, setDetailDeviceName] = useState<string>('')
const [connectError, setConnectError] = useState<string | null>(null)
const router = useRouter()
const utils = trpc.useUtils()
const listQuery = trpc.equipos.list.useQuery(
{
clienteId,
search: search.trim() || undefined,
estado: stateFilter || undefined,
sistemaOperativo: osFilter || undefined,
page: 1,
limit: 100,
},
{ refetchOnWindowFocus: false }
)
const devices = useMemo(() => {
const list = listQuery.data?.dispositivos ?? []
return list.map((d) => ({
id: d.id,
name: d.nombre,
ip: d.ip ?? '',
status: mapStateToCardStatus(d.estado),
os: d.sistemaOperativo ?? '—',
lastSeen: formatLastSeenLabel(d.estado, d.lastSeen),
}))
}, [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) => {
setConnectError(null)
setConnectingId(id)
iniciarSesionMutation.mutate({ dispositivoId: id, tipo: 'desktop' })
}
const handleFiles = (id: string) => {
router.push(`/archivos?deviceId=${encodeURIComponent(id)}`)
}
const handleTerminal = (id: string) => {
router.push(`/terminal?deviceId=${encodeURIComponent(id)}`)
}
return (
<div className="space-y-6">
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Dispositivos</h1>
<p className="mt-1 text-gray-400">Administración de equipos conectados</p>
</div>
<button
type="button"
onClick={() => setAddModalOpen(true)}
className="btn btn-primary inline-flex items-center gap-2 shrink-0"
>
<Plus className="w-4 h-4" />
Agregar Dispositivo
</button>
</header>
{connectError && (
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-3 text-sm text-red-400 flex items-center justify-between gap-2">
<span>{connectError}</span>
<button type="button" onClick={() => setConnectError(null)} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
<AddDeviceModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
clienteId={clienteId}
onSuccess={() => utils.equipos.list.invalidate()}
/>
<DeviceDetailModal
open={!!detailDeviceId}
onClose={() => setDetailDeviceId(null)}
deviceId={detailDeviceId}
deviceName={detailDeviceName}
onConnect={handleConnect}
onTerminal={handleTerminal}
onFiles={handleFiles}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar dispositivos..."
className="w-full rounded-lg border border-white/10 bg-dark-300 py-2.5 pl-10 pr-4 text-gray-200 placeholder-gray-500 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
</div>
<div className="flex flex-wrap gap-2 sm:gap-3">
<select
value={stateFilter}
onChange={(e) => setStateFilter((e.target.value || '') as StateFilter)}
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-300 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20 hover:border-white/20"
>
{STATE_OPTIONS.map((o) => (
<option key={o.value || 'all'} value={o.value}>
{o.label}
</option>
))}
</select>
<select
value={osFilter}
onChange={(e) => setOsFilter(e.target.value)}
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-300 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20 hover:border-white/20"
>
{OS_OPTIONS.map((o) => (
<option key={o.value || 'all'} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
{listQuery.isLoading ? (
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
Cargando dispositivos...
</div>
) : listQuery.isError ? (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-12 text-center text-red-400">
Error al cargar dispositivos. Intente de nuevo.
</div>
) : devices.length === 0 ? (
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
No hay dispositivos que coincidan con los filtros.
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{devices.map((device) => (
<DeviceCard
key={device.id}
id={device.id}
name={device.name}
ip={device.ip}
status={device.status}
os={device.os}
lastSeen={device.lastSeen}
onConectar={handleConnect}
onArchivos={handleFiles}
onTerminal={handleTerminal}
onInfo={(id, name) => openDetail(id, name ?? device.name)}
isConnecting={connectingId === device.id}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { useState, useMemo } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import HeadwindMetricCard from '@/components/headwind/HeadwindMetricCard'
import MobileDeviceList from '@/components/headwind/MobileDeviceList'
import CorporateAppsList from '@/components/headwind/CorporateAppsList'
import {
getMdmDashboardData,
type Device,
type AppDeployment,
type DashboardStats,
} from '@/mocks/mdmDashboardData'
export default function HeadwindPage() {
useSelectedClient()
const initial = useMemo(() => getMdmDashboardData(), [])
const [stats] = useState<DashboardStats>(initial.stats)
const [devices] = useState<Device[]>(initial.devices)
const [appDeployments] = useState<AppDeployment[]>(initial.appDeployments)
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold text-white">
Headwind MDM
</h1>
<p className="mt-1 text-gray-400">
Gestión de dispositivos móviles Android
</p>
</header>
<section
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
aria-label="Resumen"
>
<HeadwindMetricCard
label="Dispositivos Android"
value={stats.totalAndroidDevices}
subtitle="Total registrados"
accent="green"
/>
<HeadwindMetricCard
label="Apps desplegadas"
value={stats.deployedApps}
subtitle="En catálogo"
accent="blue"
/>
<HeadwindMetricCard
label="Políticas activas"
value={stats.activePolicies}
subtitle="Configuraciones aplicadas"
accent="cyan"
/>
<HeadwindMetricCard
label="Batería promedio"
value={`${stats.averageBatteryPercent}%`}
subtitle="Estado actual"
accent="amber"
/>
</section>
<section
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
aria-label="Dispositivos y apps"
>
<MobileDeviceList devices={devices} />
<CorporateAppsList apps={appDeployments} />
</section>
</div>
)
}

View File

@@ -1,37 +1,139 @@
'use client'
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Sidebar from '@/components/layout/Sidebar'
import Header from '@/components/layout/Header'
import { SelectedClientProvider, useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { trpc } from '@/lib/trpc-client'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const [alertasActivas, setAlertasActivas] = useState(0)
const [user, setUser] = useState({
nombre: 'Admin',
email: 'admin@example.com',
rol: 'SUPER_ADMIN',
const router = useRouter()
const meQuery = trpc.auth.me.useQuery(undefined, {
retry: false,
staleTime: 60 * 1000,
})
const logoutMutation = trpc.auth.logout.useMutation({
onSuccess: () => {
window.location.href = '/login'
},
})
useEffect(() => {
// TODO: Cargar alertas activas desde API
// TODO: Cargar usuario desde sesion
}, [])
const handleLogout = async () => {
// TODO: Implementar logout
window.location.href = '/login'
if (meQuery.isError) {
router.push('/login')
}
}, [meQuery.isError, router])
const handleLogout = () => {
logoutMutation.mutate()
}
if (meQuery.isLoading || meQuery.isError) {
return (
<div className="flex h-screen bg-dark-500 items-center justify-center">
<div className="text-gray-400">Cargando...</div>
</div>
)
}
const user = meQuery.data
if (!user) return null
return (
<DashboardContent user={user} onLogout={handleLogout}>
{children}
</DashboardContent>
)
}
function DashboardContent({
user,
onLogout,
children,
}: {
user: { nombre: string; email: string; rol: string }
onLogout: () => void
children: React.ReactNode
}) {
return (
<SelectedClientProvider>
<DashboardContentInner user={user} onLogout={onLogout}>
{children}
</DashboardContentInner>
</SelectedClientProvider>
)
}
function DashboardContentInner({
user,
onLogout,
children,
}: {
user: { nombre: string; email: string; rol: string }
onLogout: () => void
children: React.ReactNode
}) {
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
const activeAlertsCountQuery = trpc.alertas.conteoActivas.useQuery(
{ clienteId },
{ refetchOnWindowFocus: true, staleTime: 30 * 1000 }
)
const activeAlertsCount = activeAlertsCountQuery.data?.total ?? 0
const devicesCountQuery = trpc.equipos.list.useQuery(
{ clienteId, page: 1, limit: 1 },
{ refetchOnWindowFocus: true, staleTime: 30 * 1000 }
)
const devicesCount = devicesCountQuery.data?.pagination?.total ?? 0
const sessionsCountQuery = trpc.sesiones.count.useQuery(
{ clienteId },
{ refetchOnWindowFocus: true, staleTime: 15 * 1000 }
)
const sessionsCount = sessionsCountQuery.data ?? 0
const clientsQuery = trpc.clientes.list.useQuery(
{ limit: 100 },
{ staleTime: 60 * 1000 }
)
const clients = (clientsQuery.data?.clientes ?? []).map((c) => ({
id: c.id,
nombre: c.nombre,
codigo: c.codigo,
}))
const [sidebarOpen, setSidebarOpen] = useState(false)
return (
<div className="flex h-screen bg-dark-500">
<Sidebar alertasActivas={alertasActivas} />
<div className="flex-1 flex flex-col overflow-hidden">
<Header user={user} onLogout={handleLogout} />
<main className="flex-1 overflow-y-auto p-6">
<Sidebar
activeAlertsCount={activeAlertsCount}
devicesCount={devicesCount}
sessionsCount={sessionsCount}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<div className="ml-0 md:ml-[260px] flex min-w-0 flex-1 flex-col overflow-hidden transition-[margin] duration-200">
<Header
user={{
nombre: user.nombre,
email: user.email,
rol: user.rol,
}}
onLogout={onLogout}
clients={clients}
showAllClientsOption={user.rol === 'SUPER_ADMIN'}
onOpenSidebar={() => setSidebarOpen(true)}
/>
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
{children}
</main>
</div>

View File

@@ -0,0 +1,105 @@
'use client'
import { useState } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import LibrenmsMetricCard from '@/components/librenms/LibrenmsMetricCard'
import DeviceList from '@/components/librenms/DeviceList'
import AlertList from '@/components/librenms/AlertList'
import type { NetworkDevice } from '@/components/librenms/DeviceRow'
import type { SnmpAlert } from '@/components/librenms/AlertItem'
const MOCK_DEVICES: NetworkDevice[] = [
{ id: '1', name: 'Core Switch', model: 'Cisco 3850', status: 'online' },
{ id: '2', name: 'Router Principal', model: 'MikroTik CCR', status: 'online' },
{ id: '3', name: 'AP-Oficina-01', model: 'Ubiquiti UAP', status: 'online' },
{ id: '4', name: 'Switch-Piso2', model: 'HP ProCurve', status: 'warning' },
{ id: '5', name: 'Firewall', model: 'pfSense', status: 'online' },
]
const MOCK_ALERTS: SnmpAlert[] = [
{
id: '1',
title: 'CPU Alto Core Switch',
detail: 'Hace 15 min 92% utilización',
severity: 'critical',
},
{
id: '2',
title: 'Puerto Down Switch-Piso2',
detail: 'Hace 1h GigabitEthernet0/12',
severity: 'warning',
},
{
id: '3',
title: 'Alto tráfico WAN',
detail: 'Hace 2h 95% capacidad',
severity: 'critical',
},
]
export default function LibrenmsPage() {
useSelectedClient()
const [devices] = useState<NetworkDevice[]>(MOCK_DEVICES)
const [alerts] = useState<SnmpAlert[]>(MOCK_ALERTS)
const [metrics] = useState({
totalDevices: 28,
uptime: 99.7,
activeAlerts: 3,
trafficToday: 847,
})
return (
<div className="space-y-6">
{/* Page header */}
<header>
<h1 className="text-3xl font-bold text-white">
LibreNMS - Monitoreo de Red
</h1>
<p className="mt-1 text-gray-400">
SNMP, NetFlow y alertas de infraestructura
</p>
</header>
{/* KPI metric cards */}
<section
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
aria-label="Métricas principales"
>
<LibrenmsMetricCard
label="Dispositivos Red"
value={metrics.totalDevices}
subtitle="Switches, routers, APs"
accent="green"
/>
<LibrenmsMetricCard
label="Uptime Promedio"
value={`${metrics.uptime}%`}
subtitle="Últimos 30 días"
accent="cyan"
/>
<LibrenmsMetricCard
label="Alertas Activas"
value={metrics.activeAlerts}
subtitle="2 críticas, 1 warning"
accent="yellow"
/>
<LibrenmsMetricCard
label="Tráfico Total"
value={`${metrics.trafficToday} GB`}
subtitle="NetFlow hoy"
accent="blue"
/>
</section>
{/* Main content grid */}
<section
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
aria-label="Dispositivos y alertas"
>
<DeviceList devices={devices} />
<AlertList alerts={alerts} />
</section>
</div>
)
}

View File

@@ -1,253 +1,199 @@
'use client'
import { useState, useEffect } from 'react'
import { RefreshCw, Grid, List, Filter } from 'lucide-react'
import KPICards from '@/components/dashboard/KPICards'
import DeviceGrid from '@/components/dashboard/DeviceGrid'
import AlertsFeed from '@/components/dashboard/AlertsFeed'
import { cn } from '@/lib/utils'
import { useMemo } from 'react'
import Link from 'next/link'
import { Monitor, CheckCircle, XCircle, AlertTriangle, Plus } from 'lucide-react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { 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'
// 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 = 8
export default function DashboardPage() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [isRefreshing, setIsRefreshing] = useState(false)
const [stats, setStats] = useState(mockStats)
const [devices, setDevices] = useState(mockDevices)
const [alerts, setAlerts] = useState(mockAlerts)
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
const handleRefresh = async () => {
setIsRefreshing(true)
// TODO: Recargar datos de la API
await new Promise((resolve) => setTimeout(resolve, 1000))
setIsRefreshing(false)
}
const handleDeviceAction = (deviceId: string, action: string) => {
console.log(`Action ${action} on device ${deviceId}`)
// TODO: Implementar acciones
}
const handleAcknowledgeAlert = (alertId: string) => {
setAlerts((prev) =>
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RECONOCIDA' as const } : a))
const statsQuery = trpc.clientes.dashboardStats.useQuery(
{ clienteId },
{ refetchOnWindowFocus: false }
)
// TODO: Llamar API
const equiposQuery = trpc.equipos.list.useQuery(
{ page: 1, limit: DEVICES_LIMIT, clienteId },
{ refetchOnWindowFocus: false }
)
const redQuery = trpc.red.list.useQuery(
{ page: 1, limit: DEVICES_LIMIT, clienteId },
{ refetchOnWindowFocus: false }
)
const celularesQuery = trpc.celulares.list.useQuery(
{ page: 1, limit: DEVICES_LIMIT, clienteId },
{ refetchOnWindowFocus: false }
)
const alertsQuery = trpc.alertas.list.useQuery(
{ page: 1, limit: 15, clienteId },
{ refetchOnWindowFocus: false }
)
const recentActivity: RecentActivityItem[] = useMemo(() => {
const list = alertsQuery.data?.alertas ?? []
return list.map((a) => ({
id: a.id,
type: 'alert' as const,
description: a.titulo,
deviceName: a.dispositivo?.nombre ?? '—',
timestamp: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
severity: a.severidad,
}))
}, [alertsQuery.data])
const stats = statsQuery.data ?? {
totalDispositivos: 0,
dispositivosOnline: 0,
dispositivosOffline: 0,
dispositivosAlerta: 0,
alertasActivas: 0,
alertasCriticas: 0,
sesionesActivas: 0,
}
const handleResolveAlert = (alertId: string) => {
setAlerts((prev) =>
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RESUELTA' as const } : a))
)
// TODO: Llamar API
const deviceStatusBreakdown = {
online: stats.dispositivosOnline,
offline: stats.dispositivosOffline,
advertencia: stats.dispositivosAlerta,
}
const allDevices = [
...(equiposQuery.data?.dispositivos ?? []),
...(redQuery.data?.dispositivos ?? []),
...(celularesQuery.data?.dispositivos ?? []),
].slice(0, DEVICES_LIMIT)
const quickConnections: QuickConnectionItem[] =
allDevices.length > 0
? allDevices.map((d) => ({
id: d.id,
name: d.nombre,
status: mapEstadoToQuickStatus(d.estado),
}))
: MOCK_QUICK_CONNECTIONS
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-gray-500">Vision general del sistema</p>
<h1 className="text-3xl font-bold text-white">MSP-CAS Dashboard</h1>
<p className="mt-1 text-gray-400">
MeshCentral + LibreNMS + Headwind MDM unificados
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
className="btn btn-secondary"
disabled={isRefreshing}
<div className="flex shrink-0">
<Link
href="/devices"
className="btn btn-primary inline-flex items-center gap-2"
>
<RefreshCw className={cn('w-4 h-4 mr-2', isRefreshing && 'animate-spin')} />
Actualizar
</button>
</div>
<Plus className="w-4 h-4" />
Agregar Dispositivo
</Link>
</div>
</header>
{/* KPI Cards */}
<KPICards stats={stats} />
<section
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
aria-label="Resumen"
>
<SummaryCard
title="Total Dispositivos"
value={stats.totalDispositivos}
secondary={MOCK_DASHBOARD_SECONDARY.total}
icon={<Monitor className="w-6 h-6" />}
iconBgClass="bg-primary-900/30"
iconColorClass="text-primary-400"
/>
<SummaryCard
title="En Línea"
value={stats.dispositivosOnline}
secondary={
stats.totalDispositivos > 0
? `${Math.round((stats.dispositivosOnline / stats.totalDispositivos) * 100)}% disponibilidad`
: MOCK_DASHBOARD_SECONDARY.online
}
icon={<CheckCircle className="w-6 h-6" />}
iconBgClass="bg-success/20"
iconColorClass="text-success"
/>
<SummaryCard
title="Fuera de Línea"
value={stats.dispositivosOffline}
secondary={MOCK_DASHBOARD_SECONDARY.offline}
icon={<XCircle className="w-6 h-6" />}
iconBgClass="bg-gray-500/20"
iconColorClass="text-gray-400"
/>
<SummaryCard
title="Advertencias"
value={stats.dispositivosAlerta}
secondary={MOCK_DASHBOARD_SECONDARY.alerta}
icon={<AlertTriangle className="w-6 h-6" />}
iconBgClass="bg-warning/20"
iconColorClass="text-warning"
/>
</section>
{/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Devices */}
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Dispositivos</h2>
<div className="flex items-center gap-2">
<button className="btn btn-ghost btn-sm">
<Filter className="w-4 h-4 mr-1" />
Filtrar
</button>
<div className="flex border border-dark-100 rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={cn(
'p-2 transition-colors',
viewMode === 'grid' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
)}
>
<Grid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={cn(
'p-2 transition-colors',
viewMode === 'list' ? 'bg-dark-100 text-primary-400' : 'text-gray-500'
)}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
<section className="lg:col-span-2">
<DeviceStatusChart data={deviceStatusBreakdown} />
</section>
<section className="min-h-0">
<RecentActivityList items={recentActivity} isLoading={alertsQuery.isLoading} />
</section>
</div>
<DeviceGrid
devices={devices}
viewMode={viewMode}
onAction={handleDeviceAction}
/>
<section>
<h2 className="text-lg font-medium text-gray-200 mb-4">Salud del Sistema</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.cpu} />
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.ram} />
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.network} />
</div>
</section>
{/* Alerts */}
<div>
<AlertsFeed
alerts={alerts}
onAcknowledge={handleAcknowledgeAlert}
onResolve={handleResolveAlert}
<section>
<h2 className="text-lg font-medium text-gray-200 mb-4">Conexión Rápida</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{quickConnections.map((item) => (
<QuickConnectionCard
key={item.id}
item={item}
onClick={(id) => {
// TODO: router.push(`/devices?id=${id}`) or open device detail modal
}}
/>
))}
</div>
</div>
</section>
</div>
)
}
function mapEstadoToQuickStatus(estado: string): QuickConnectionStatus {
const u = estado?.toUpperCase()
if (u === 'ONLINE') return 'online'
if (u === 'ALERTA') return 'advertencia'
return 'offline'
}

View File

@@ -0,0 +1,221 @@
'use client'
import { useState, useMemo, useEffect, useCallback } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { trpc } from '@/lib/trpc-client'
import MetricCard from '@/components/performance/MetricCard'
import ProcessTable from '@/components/performance/ProcessTable'
import type { ProcessItem } from '@/components/performance/ProcessRow'
const CHART_POINTS = 20
const POLL_INTERVAL_MS = 2000
function randomIn(min: number, max: number) {
return Math.round(min + Math.random() * (max - min))
}
function generateMockProcesses(): ProcessItem[] {
const names = [
'chrome.exe',
'Code.exe',
'node.exe',
'System',
'svchost.exe',
'explorer.exe',
'MsMpEng.exe',
'SearchHost.exe',
'RuntimeBroker.exe',
'dllhost.exe',
]
return names.slice(0, 10).map((name, i) => ({
id: `p-${i}`,
name,
pid: 1000 + i * 100 + randomIn(0, 99),
cpu: randomIn(0, 45),
memory: `${randomIn(50, 800)} MB`,
state: i % 3 === 0 ? 'En ejecución' : 'Activo',
}))
}
export default function RendimientoPage() {
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
const listQuery = trpc.equipos.list.useQuery(
{ clienteId, limit: 100 },
{ refetchOnWindowFocus: false }
)
const devices = useMemo(
() => (listQuery.data?.dispositivos ?? []).map((d) => ({ id: d.id, nombre: d.nombre })),
[listQuery.data]
)
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
const [metrics, setMetrics] = useState({
cpu: 0,
memory: 0,
disk: 0,
network: 0,
})
const [chartHistory, setChartHistory] = useState<{
cpu: number[]
memory: number[]
disk: number[]
network: number[]
}>({
cpu: [],
memory: [],
disk: [],
network: [],
})
const [processes, setProcesses] = useState<ProcessItem[]>([])
const [loading, setLoading] = useState(false)
const hasDevice = !!selectedDeviceId
const tick = useCallback(() => {
const cpu = randomIn(15, 85)
const memory = randomIn(40, 90)
const disk = randomIn(25, 65)
const network = randomIn(5, 120)
setMetrics({ cpu, memory, disk, network })
setChartHistory((prev) => ({
cpu: [...prev.cpu, cpu].slice(-CHART_POINTS),
memory: [...prev.memory, memory].slice(-CHART_POINTS),
disk: [...prev.disk, disk].slice(-CHART_POINTS),
network: [...prev.network, network].slice(-CHART_POINTS),
}))
setProcesses(generateMockProcesses())
}, [])
useEffect(() => {
if (!hasDevice) {
setMetrics({ cpu: 0, memory: 0, disk: 0, network: 0 })
setChartHistory({ cpu: [], memory: [], disk: [], network: [] })
setProcesses([])
return
}
setLoading(true)
tick()
const t = setTimeout(() => setLoading(false), 400)
const interval = setInterval(tick, POLL_INTERVAL_MS)
return () => {
clearTimeout(t)
clearInterval(interval)
}
}, [hasDevice, tick])
const cpuFooter = hasDevice
? [
{ label: 'Procesos:', value: '142' },
{ label: 'Hilos:', value: '2,840' },
{ label: 'Velocidad:', value: '2.90 GHz' },
]
: [
{ label: 'Procesos:', value: '—' },
{ label: 'Hilos:', value: '—' },
{ label: 'Velocidad:', value: '—' },
]
const ramFooter = hasDevice
? [
{ label: 'En uso:', value: `${Math.round((metrics.memory / 100) * 16)} GB` },
{ label: 'Disponible:', value: `${Math.round(((100 - metrics.memory) / 100) * 16)} GB` },
{ label: 'Total:', value: '16 GB' },
]
: [
{ label: 'En uso:', value: '—' },
{ label: 'Disponible:', value: '—' },
{ label: 'Total:', value: '—' },
]
const diskFooter = hasDevice
? [
{ label: 'Lectura:', value: '12.5 MB/s' },
{ label: 'Escritura:', value: '3.2 MB/s' },
{ label: 'Activo:', value: 'Sí' },
]
: [
{ label: 'Lectura:', value: '—' },
{ label: 'Escritura:', value: '—' },
{ label: 'Activo:', value: '—' },
]
const networkFooter = hasDevice
? [
{ label: 'Enviado:', value: '1.2 GB' },
{ label: 'Recibido:', value: '4.8 GB' },
{ label: 'Adaptador:', value: 'Ethernet' },
]
: [
{ label: 'Enviado:', value: '—' },
{ label: 'Recibido:', value: '—' },
{ label: 'Adaptador:', value: '—' },
]
return (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Rendimiento en Tiempo Real
</h1>
<p className="mt-1 text-gray-400">
Monitorea recursos del sistema
</p>
</div>
<div className="shrink-0">
<select
value={selectedDeviceId}
onChange={(e) => setSelectedDeviceId(e.target.value)}
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value="">-- Seleccionar dispositivo --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.nombre}
</option>
))}
</select>
</div>
</header>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<MetricCard
title="CPU"
value={hasDevice ? `${metrics.cpu}` : '—'}
valueSuffix="%"
footerStats={cpuFooter}
chartData={chartHistory.cpu}
highUsage={metrics.cpu > 80}
/>
<MetricCard
title="Memoria RAM"
value={hasDevice ? `${metrics.memory}` : '—'}
valueSuffix="%"
footerStats={ramFooter}
chartData={chartHistory.memory}
highUsage={metrics.memory > 80}
/>
<MetricCard
title="Disco"
value={hasDevice ? `${metrics.disk}` : '—'}
valueSuffix="%"
footerStats={diskFooter}
chartData={chartHistory.disk}
highUsage={metrics.disk > 80}
/>
<MetricCard
title="Red"
value={hasDevice ? `${metrics.network}` : '—'}
valueSuffix="Mbps"
footerStats={networkFooter}
chartData={chartHistory.network}
/>
</div>
<ProcessTable processes={processes} noDevice={!hasDevice} />
</div>
)
}

View File

@@ -0,0 +1,185 @@
'use client'
// TODO: replace mock reportService with trpc.reportes.inventario / reportes.alertas / reportes.exportarCSV and real PDF generation
import { useState, useCallback } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import DateRangeFilter, { type DateRangeValue } from '@/components/reportes/DateRangeFilter'
import ReportCard from '@/components/reportes/ReportCard'
import {
fetchInventoryReport,
fetchResourceUsageReport,
fetchAlertsReport,
} from '@/mocks/reportService'
function defaultDateRange(): DateRangeValue {
const today = new Date()
const monthAgo = new Date(today)
monthAgo.setMonth(monthAgo.getMonth() - 1)
return {
desde: monthAgo.toISOString().split('T')[0],
hasta: today.toISOString().split('T')[0],
}
}
export default function ReportesPage() {
useSelectedClient()
const [filters, setFilters] = useState<DateRangeValue>(defaultDateRange)
const [loading, setLoading] = useState<string | null>(null)
const getDates = useCallback((): { start: Date; end: Date } | null => {
if (!filters.desde || !filters.hasta || filters.hasta < filters.desde)
return null
return {
start: new Date(filters.desde),
end: new Date(filters.hasta),
}
}, [filters])
const mockExportPdf = useCallback(
(reportName: string) => {
const dates = getDates()
console.log(`[Mock] Export PDF: ${reportName}`, dates || filters)
},
[getDates, filters]
)
const mockExportExcel = useCallback(
(reportName: string) => {
const dates = getDates()
const csv = `Reporte,${reportName}\nDesde,${filters.desde || '-'}\nHasta,${filters.hasta || '-'}\n`
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `reporte-${reportName.toLowerCase().replace(/\s+/g, '-')}-${filters.desde || 'export'}.csv`
a.click()
URL.revokeObjectURL(url)
},
[getDates, filters]
)
const handleInventoryPdf = () => {
setLoading('inventario-pdf')
const dates = getDates()
if (dates) {
fetchInventoryReport(dates.start, dates.end).then(() => {
mockExportPdf('Inventario de Equipos')
setLoading(null)
})
} else {
mockExportPdf('Inventario de Equipos')
setLoading(null)
}
}
const handleInventoryExcel = () => {
setLoading('inventario-excel')
const dates = getDates()
if (dates) {
fetchInventoryReport(dates.start, dates.end).then(() => {
mockExportExcel('Inventario de Equipos')
setLoading(null)
})
} else {
mockExportExcel('Inventario de Equipos')
setLoading(null)
}
}
const handleResourcePdf = () => {
setLoading('recursos-pdf')
const dates = getDates()
if (dates) {
fetchResourceUsageReport(dates.start, dates.end).then(() => {
mockExportPdf('Uso de Recursos')
setLoading(null)
})
} else {
mockExportPdf('Uso de Recursos')
setLoading(null)
}
}
const handleResourceExcel = () => {
setLoading('recursos-excel')
const dates = getDates()
if (dates) {
fetchResourceUsageReport(dates.start, dates.end).then(() => {
mockExportExcel('Uso de Recursos')
setLoading(null)
})
} else {
mockExportExcel('Uso de Recursos')
setLoading(null)
}
}
const handleAlertsPdf = () => {
setLoading('alertas-pdf')
const dates = getDates()
if (dates) {
fetchAlertsReport(dates.start, dates.end).then(() => {
mockExportPdf('Historial de Alertas')
setLoading(null)
})
} else {
mockExportPdf('Historial de Alertas')
setLoading(null)
}
}
const handleAlertsExcel = () => {
setLoading('alertas-excel')
const dates = getDates()
if (dates) {
fetchAlertsReport(dates.start, dates.end).then(() => {
mockExportExcel('Historial de Alertas')
setLoading(null)
})
} else {
mockExportExcel('Historial de Alertas')
setLoading(null)
}
}
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold text-white">Reportes</h1>
<p className="mt-1 text-gray-400">Generación de informes del sistema</p>
</header>
<section className="py-4">
<DateRangeFilter value={filters} onChange={setFilters} />
</section>
<section
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
aria-label="Reportes disponibles"
>
<ReportCard
title="Inventario de Equipos"
description="Listado completo de todos los dispositivos registrados con especificaciones técnicas y estado actual."
onExportPdf={handleInventoryPdf}
onExportExcel={handleInventoryExcel}
loading={loading?.startsWith('inventario') ?? false}
/>
<ReportCard
title="Uso de Recursos"
description="Estadísticas de consumo de CPU, memoria y red de todos los dispositivos en el periodo seleccionado."
onExportPdf={handleResourcePdf}
onExportExcel={handleResourceExcel}
loading={loading?.startsWith('recursos') ?? false}
/>
<ReportCard
title="Historial de Alertas"
description="Registro completo de todas las alertas generadas, incluyendo resolución y tiempo de respuesta."
onExportPdf={handleAlertsPdf}
onExportExcel={handleAlertsExcel}
loading={loading?.startsWith('alertas') ?? false}
/>
</section>
</div>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import { trpc } from '@/lib/trpc-client'
import { formatDurationSeconds } from '@/lib/utils'
import SessionCard, { type SessionTypeLabel } from '@/components/sessions/SessionCard'
const TIPO_TO_LABEL: Record<string, SessionTypeLabel> = {
desktop: 'Escritorio Remoto',
terminal: 'Terminal',
files: 'Archivos',
}
function getSessionTypeLabel(tipo: string): SessionTypeLabel {
return TIPO_TO_LABEL[tipo] ?? 'Terminal'
}
function computeDurationSeconds(startedAt: Date | string): number {
const start = new Date(startedAt).getTime()
return Math.floor((Date.now() - start) / 1000)
}
export default function SesionesPage() {
const { selectedClientId } = useSelectedClient()
const clienteId = selectedClientId ?? undefined
const utils = trpc.useUtils()
const listQuery = trpc.sesiones.list.useQuery(
{ clienteId, limit: 100 },
{ refetchOnWindowFocus: true, staleTime: 10 * 1000 }
)
const [liveDurations, setLiveDurations] = useState<Record<string, number>>({})
const sessions = useMemo(() => {
const list = listQuery.data?.sessions ?? []
return list.map((s) => {
const startedAt = s.iniciadaEn instanceof Date ? s.iniciadaEn : new Date(s.iniciadaEn)
const seconds = computeDurationSeconds(startedAt)
return {
id: s.id,
deviceName: s.dispositivo.nombre,
userEmail: s.usuario.email,
sessionType: getSessionTypeLabel(s.tipo),
startedAt,
initialSeconds: seconds,
}
})
}, [listQuery.data])
useEffect(() => {
if (sessions.length === 0) return
const interval = setInterval(() => {
const next: Record<string, number> = {}
sessions.forEach((s) => {
next[s.id] = computeDurationSeconds(s.startedAt)
})
setLiveDurations((prev) => ({ ...prev, ...next }))
}, 1000)
return () => clearInterval(interval)
}, [sessions])
const endSessionMutation = trpc.equipos.finalizarSesion.useMutation({
onSuccess: () => {
utils.sesiones.list.invalidate()
utils.sesiones.count.invalidate()
utils.clientes.dashboardStats.invalidate()
},
})
const handleEndSession = (sessionId: string) => {
if (typeof window !== 'undefined' && !window.confirm('¿Terminar esta sesión?')) return
endSessionMutation.mutate({ sesionId: sessionId })
}
if (listQuery.isLoading) {
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
</header>
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
Cargando sesiones...
</div>
</div>
)
}
if (listQuery.isError) {
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
</header>
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-12 text-center text-red-400">
Error al cargar sesiones. Intente de nuevo.
</div>
</div>
)
}
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
</header>
{sessions.length === 0 ? (
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
No hay sesiones activas.
</div>
) : (
<ul className="space-y-5">
{sessions.map((session) => {
const durationSeconds = liveDurations[session.id] ?? session.initialSeconds
const duration = formatDurationSeconds(durationSeconds)
const isEnding = endSessionMutation.isPending && endSessionMutation.variables?.sesionId === session.id
return (
<li key={session.id}>
<SessionCard
id={session.id}
deviceName={session.deviceName}
userEmail={session.userEmail}
sessionType={session.sessionType}
duration={duration}
onEnd={handleEndSession}
isEnding={isEnding}
/>
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,207 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
import SummaryMetricCard from '@/components/software/SummaryMetricCard'
import SoftwareTable from '@/components/software/SoftwareTable'
import type { SoftwareItem } from '@/components/software/SoftwareRow'
import { Download } from 'lucide-react'
const MOCK_SOFTWARE: SoftwareItem[] = [
{
id: '1',
name: 'Google Chrome',
version: '120.0.6099.130',
vendor: 'Google LLC',
installations: 10,
lastUpdate: '15/01/2024',
licensed: false,
needsUpdate: true,
},
{
id: '2',
name: 'Microsoft Office 365',
version: '16.0.17029.20028',
vendor: 'Microsoft Corporation',
installations: 8,
lastUpdate: '20/01/2024',
licensed: true,
needsUpdate: false,
},
{
id: '3',
name: 'Adobe Acrobat Reader DC',
version: '23.006.20360',
vendor: 'Adobe Inc.',
installations: 12,
lastUpdate: '10/01/2024',
licensed: true,
needsUpdate: true,
},
{
id: '4',
name: 'Visual Studio Code',
version: '1.85.1',
vendor: 'Microsoft Corporation',
installations: 5,
lastUpdate: '18/01/2024',
licensed: false,
needsUpdate: false,
},
{
id: '5',
name: 'Slack',
version: '4.33.90',
vendor: 'Slack Technologies',
installations: 7,
lastUpdate: '12/01/2024',
licensed: true,
needsUpdate: false,
},
{
id: '6',
name: 'Zoom',
version: '5.16.10',
vendor: 'Zoom Video Communications',
installations: 6,
lastUpdate: '08/01/2024',
licensed: false,
needsUpdate: true,
},
{
id: '7',
name: '7-Zip',
version: '23.01',
vendor: 'Igor Pavlov',
installations: 15,
lastUpdate: '22/12/2023',
licensed: false,
needsUpdate: false,
},
{
id: '8',
name: 'Node.js',
version: '20.10.0',
vendor: 'OpenJS Foundation',
installations: 4,
lastUpdate: '05/01/2024',
licensed: false,
needsUpdate: true,
},
{
id: '9',
name: 'Git',
version: '2.43.0',
vendor: 'Software Freedom Conservancy',
installations: 5,
lastUpdate: '14/01/2024',
licensed: false,
needsUpdate: false,
},
{
id: '10',
name: 'Windows Security',
version: '10.0.22621.1',
vendor: 'Microsoft Corporation',
installations: 12,
lastUpdate: '19/01/2024',
licensed: true,
needsUpdate: false,
},
]
function exportToCsv(items: SoftwareItem[]): string {
const headers = ['Nombre', 'Versión', 'Editor', 'Instalaciones', 'Última actualización']
const rows = items.map((i) =>
[i.name, i.version, i.vendor, i.installations, i.lastUpdate].join(',')
)
return [headers.join(','), ...rows].join('\n')
}
export default function InventarioPage() {
useSelectedClient()
const [softwareList] = useState<SoftwareItem[]>(MOCK_SOFTWARE)
const [search, setSearch] = useState('')
const filteredSoftware = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return softwareList
return softwareList.filter((item) =>
item.name.toLowerCase().includes(q) ||
item.vendor.toLowerCase().includes(q) ||
item.version.toLowerCase().includes(q)
)
}, [softwareList, search])
const uniqueCount = filteredSoftware.length
const licensedCount = useMemo(
() => filteredSoftware.filter((i) => i.licensed).length,
[filteredSoftware]
)
const needsUpdateCount = useMemo(
() => filteredSoftware.filter((i) => i.needsUpdate).length,
[filteredSoftware]
)
const handleExport = useCallback(() => {
const csv = exportToCsv(filteredSoftware)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `inventario-software-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}, [filteredSoftware])
return (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
Inventario de Software
</h1>
<p className="mt-1 text-gray-400">
Software instalado en los dispositivos
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar software..."
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 transition-colors focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
/>
<button
type="button"
onClick={handleExport}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-cyan-500"
>
<Download className="h-4 w-4" />
Exportar
</button>
</div>
</header>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<SummaryMetricCard
value={uniqueCount}
label="Programas Únicos"
/>
<SummaryMetricCard
value={licensedCount}
label="Con Licencia"
/>
<SummaryMetricCard
value={needsUpdateCount}
label="Requieren Actualización"
/>
</div>
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<SoftwareTable items={filteredSoftware} />
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,13 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/trpc/routers'
import { createContext } from '@/server/trpc/trpc'
const handler = (request: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req: request,
router: appRouter,
createContext: () => createContext(),
})
export { handler as GET, handler as POST }

View File

@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import TrpcProvider from '@/components/providers/TrpcProvider'
const inter = Inter({ subsets: ['latin'] })
@@ -20,7 +21,7 @@ export default function RootLayout({
return (
<html lang="es" className="dark">
<body className={`${inter.className} dark`}>
{children}
<TrpcProvider>{children}</TrpcProvider>
</body>
</html>
)

105
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,105 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const meQuery = trpc.auth.me.useQuery(undefined, { retry: false })
useEffect(() => {
if (meQuery.data) router.push('/')
}, [meQuery.data, router])
const loginMutation = trpc.auth.login.useMutation({
onSuccess: () => {
router.push('/')
router.refresh()
},
onError: (err) => {
setError(err.message ?? 'Credenciales inválidas')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setError(null)
loginMutation.mutate({ email, password })
}
if (meQuery.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-dark-500">
<div className="text-gray-400">Cargando...</div>
</div>
)
}
if (meQuery.data) return null
return (
<div className="min-h-screen flex items-center justify-center bg-dark-500 p-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-bold text-white">MSP Monitor</h1>
<p className="text-gray-400 mt-1">Iniciar sesión</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4 bg-dark-400 p-6 rounded-lg border border-dark-100">
{error && (
<div className="p-3 rounded bg-danger/20 text-danger text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="input w-full"
placeholder="admin@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
Contraseña
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="input w-full"
/>
</div>
<button
type="submit"
disabled={loginMutation.isPending}
className="btn btn-primary w-full"
>
{loginMutation.isPending ? 'Entrando...' : 'Entrar'}
</button>
</form>
{/*<p className="text-center text-gray-500 text-sm">
Por defecto: admin@example.com / Admin123!
</p>
*/}
</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
'use client'
import { cn, formatRelativeTime } from '@/lib/utils'
export type AlertSeverity = 'CRITICAL' | 'WARNING' | 'INFO'
export type AlertStatus = 'ACTIVA' | 'RECONOCIDA' | 'RESUELTA'
export interface AlertCardData {
id: string
title: string
device: string
description: string
severity: AlertSeverity
timestamp: Date | string
status: AlertStatus
}
interface AlertCardProps {
alert: AlertCardData
onAcknowledge?: (id: string) => void
onResolve?: (id: string) => void
}
const severityStyles = {
CRITICAL: {
bar: 'bg-red-500',
badge: 'bg-red-500/20 text-red-400 border-red-500/40',
label: 'CRÍTICO',
},
WARNING: {
bar: 'bg-amber-500',
badge: 'bg-amber-500/20 text-amber-400 border-amber-500/40',
label: 'ADVERTENCIA',
},
INFO: {
bar: 'bg-blue-500',
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/40',
label: 'INFO',
},
}
export default function AlertCard({ alert, onAcknowledge, onResolve }: AlertCardProps) {
const style = severityStyles[alert.severity]
const ts = typeof alert.timestamp === 'string' ? new Date(alert.timestamp) : alert.timestamp
return (
<div
className={cn(
'rounded-xl border border-slate-700/60 bg-slate-800/50 shadow-sm',
'p-5 transition-all duration-200 hover:border-slate-600 hover:shadow-md',
'flex gap-4'
)}
>
<div className={cn('w-1 shrink-0 rounded-full', style.bar)} aria-hidden />
<div className="min-w-0 flex-1">
<h4 className="font-semibold text-slate-100 text-base leading-tight">
{alert.title}
</h4>
<p className="mt-1 text-sm text-slate-400">{alert.device}</p>
<p className="mt-2 text-sm text-slate-500 line-clamp-2">{alert.description}</p>
<p className="mt-2 text-xs text-slate-600">{formatRelativeTime(ts)}</p>
</div>
<div className="flex shrink-0 flex-col items-end justify-between gap-3">
<span
className={cn(
'rounded-md border px-2 py-0.5 text-xs font-medium',
style.badge
)}
>
{style.label}
</span>
{alert.status === 'ACTIVA' && (
<div className="flex gap-2">
<button
type="button"
onClick={() => onAcknowledge?.(alert.id)}
className="btn btn-secondary btn-sm"
>
Marcar leído
</button>
<button
type="button"
onClick={() => onResolve?.(alert.id)}
className="btn btn-ghost btn-sm"
>
Descartar
</button>
</div>
)}
{alert.status !== 'ACTIVA' && (
<span className="text-xs text-slate-500">
{alert.status === 'RECONOCIDA' ? 'Leída' : 'Resuelta'}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import type { AlertCardData, AlertSeverity } from './AlertCard'
import AlertCard from './AlertCard'
import AlertsTabs, { type AlertsTab } from './AlertsTabs'
import { useMemo, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
interface AlertsSectionProps {
alerts: AlertCardData[]
isLoading?: boolean
onAcknowledge?: (id: string) => void
onResolve?: (id: string) => void
}
function filterByTab(alerts: AlertCardData[], tab: AlertsTab): AlertCardData[] {
if (tab === 'all') return alerts
return alerts.filter((a) => a.severity === tab)
}
export default function AlertsSection({
alerts,
isLoading,
onAcknowledge,
onResolve,
}: AlertsSectionProps) {
const [activeTab, setActiveTab] = useState<AlertsTab>('all')
const filtered = useMemo(
() => filterByTab(alerts, activeTab),
[alerts, activeTab]
)
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-100">Alertas del Sistema</h1>
<p className="mt-1 text-slate-400">Notificaciones y advertencias</p>
</div>
<AlertsTabs active={activeTab} onChange={setActiveTab} />
{isLoading ? (
<div className="rounded-xl border border-slate-700/60 bg-slate-800/50 p-12 text-center text-slate-400">
Cargando alertas...
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/50 p-12 text-center">
<AlertTriangle className="h-12 w-12 text-slate-500" />
<p className="mt-3 text-slate-400">
{activeTab === 'all'
? 'No hay alertas'
: `No hay alertas de tipo ${activeTab === 'CRITICAL' ? 'críticas' : activeTab === 'WARNING' ? 'advertencias' : 'informativas'}`}
</p>
</div>
) : (
<div className="space-y-4">
{filtered.map((alert) => (
<AlertCard
key={alert.id}
alert={alert}
onAcknowledge={onAcknowledge}
onResolve={onResolve}
/>
))}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,39 @@
'use client'
import { cn } from '@/lib/utils'
export type AlertsTab = 'all' | 'CRITICAL' | 'WARNING' | 'INFO'
const TABS: { id: AlertsTab; label: string }[] = [
{ id: 'all', label: 'Todas' },
{ id: 'CRITICAL', label: 'Críticas' },
{ id: 'WARNING', label: 'Advertencias' },
{ id: 'INFO', label: 'Informativas' },
]
interface AlertsTabsProps {
active: AlertsTab
onChange: (tab: AlertsTab) => void
}
export default function AlertsTabs({ active, onChange }: AlertsTabsProps) {
return (
<div className="flex gap-1 rounded-lg bg-slate-800/50 p-1">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={cn(
'rounded-md px-4 py-2 text-sm font-medium transition-all duration-200',
active === tab.id
? 'bg-slate-700 text-slate-100 shadow-sm ring-1 ring-slate-600'
: 'text-slate-400 hover:bg-slate-700/50 hover:text-slate-300'
)}
>
{tab.label}
</button>
))}
</div>
)
}

View File

@@ -1,5 +1,6 @@
'use client'
import Link from 'next/link'
import { AlertTriangle, CheckCircle, Info, Clock } from 'lucide-react'
import { cn, formatRelativeTime } from '@/lib/utils'
@@ -42,9 +43,9 @@ export default function AlertsFeed({
<div className="card overflow-hidden">
<div className="card-header flex items-center justify-between">
<h3 className="font-medium">Alertas Recientes</h3>
<a href="/alertas" className="text-sm text-primary-500 hover:underline">
<Link href="/alerts" className="text-sm text-primary-500 hover:underline">
Ver todas
</a>
</Link>
</div>
<div className="divide-y divide-dark-100">

View File

@@ -20,7 +20,7 @@ import {
Terminal,
FolderOpen,
} from 'lucide-react'
import { cn, formatRelativeTime, getStatusColor, getStatusBgColor } from '@/lib/utils'
import { cn, formatRelativeTime, getStatusColor, getStatusBgColor, getStatusBorderColor } from '@/lib/utils'
interface Device {
id: string
@@ -80,7 +80,7 @@ function DeviceCard({
const getDeviceUrl = () => {
const type = device.tipo
if (['PC', 'LAPTOP', 'SERVIDOR'].includes(type)) return `/equipos/${device.id}`
if (['PC', 'LAPTOP', 'SERVIDOR'].includes(type)) return `/devices/${device.id}`
if (['CELULAR', 'TABLET'].includes(type)) return `/celulares/${device.id}`
return `/red/${device.id}`
}
@@ -88,25 +88,14 @@ function DeviceCard({
return (
<div
className={cn(
'card p-4 transition-all hover:border-primary-500/50 relative group',
device.estado === 'ALERTA' && 'border-danger/50'
'card p-4 transition-all hover:border-primary-500/50 relative group border',
getStatusBorderColor(device.estado)
)}
>
{/* Status indicator */}
<div className="absolute top-3 right-3 flex items-center gap-2">
<span
className={cn(
'status-dot',
device.estado === 'ONLINE' && 'status-dot-online',
device.estado === 'OFFLINE' && 'status-dot-offline',
device.estado === 'ALERTA' && 'status-dot-alert',
device.estado === 'MANTENIMIENTO' && 'status-dot-maintenance'
)}
/>
<div className="relative">
<div className="absolute top-3 right-3 z-10">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-dark-100 opacity-0 group-hover:opacity-100 transition-opacity"
className="p-1.5 rounded hover:bg-dark-100 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity touch-manipulation"
>
<MoreVertical className="w-4 h-4 text-gray-500" />
</button>
@@ -164,12 +153,10 @@ function DeviceCard({
</>
)}
</div>
</div>
{/* Icon and name */}
<Link href={getDeviceUrl()} className="block">
<div className="flex items-center gap-4 mb-3">
<div className={cn('p-3 rounded-lg', getStatusBgColor(device.estado))}>
<div className={cn('p-3 rounded-lg shrink-0', getStatusBgColor(device.estado))}>
<span className={getStatusColor(device.estado)}>
{deviceIcons[device.tipo] || deviceIcons.OTRO}
</span>
@@ -204,9 +191,9 @@ function DeviceCard({
</div>
{/* Metrics bar */}
{device.estado === 'ONLINE' && (device.cpuUsage !== null || device.ramUsage !== null) && (
{device.estado === 'ONLINE' && (device.cpuUsage != null || device.ramUsage != null) && (
<div className="mt-3 pt-3 border-t border-dark-100 grid grid-cols-2 gap-2">
{device.cpuUsage !== null && (
{device.cpuUsage != null && (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-500">CPU</span>
@@ -225,7 +212,7 @@ function DeviceCard({
</div>
</div>
)}
{device.ramUsage !== null && (
{device.ramUsage != null && (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-500">RAM</span>
@@ -304,7 +291,7 @@ function DeviceList({
</span>
</td>
<td>
{device.cpuUsage !== null ? (
{device.cpuUsage != null ? (
<span className={cn(device.cpuUsage > 80 ? 'text-danger' : 'text-gray-400')}>
{Math.round(device.cpuUsage)}%
</span>
@@ -313,7 +300,7 @@ function DeviceList({
)}
</td>
<td>
{device.ramUsage !== null ? (
{device.ramUsage != null ? (
<span className={cn(device.ramUsage > 80 ? 'text-danger' : 'text-gray-400')}>
{Math.round(device.ramUsage)}%
</span>
@@ -326,7 +313,7 @@ function DeviceList({
</td>
<td>
<Link
href={`/equipos/${device.id}`}
href={`/devices/${device.id}`}
className="btn btn-ghost btn-sm"
>
Ver

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,143 @@
'use client'
import { Monitor, FolderOpen, Terminal, Info, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
export type DeviceCardStatus = 'online' | 'offline' | 'warning'
export type DeviceCardOS = 'Windows' | 'Linux' | 'macOS' | string
export interface DeviceCardProps {
id?: string
name: string
ip: string
status: DeviceCardStatus
os: DeviceCardOS
lastSeen: string
onConectar?: (id: string) => void
onArchivos?: (id: string) => void
onTerminal?: (id: string) => void
onInfo?: (id: string, name?: string) => void
isConnecting?: boolean
}
const statusConfig: Record<
DeviceCardStatus,
{ label: string; className: string }
> = {
online: { label: 'En línea', className: 'bg-emerald-600/90 text-white' },
offline: { label: 'Fuera de línea', className: 'bg-red-600/90 text-white' },
warning: { label: 'Advertencia', className: 'bg-amber-500/90 text-white' },
}
function normalizeOS(os: string): string {
const u = os.toLowerCase()
if (u.includes('windows')) return 'Windows'
if (u.includes('linux') || u.includes('ubuntu') || u.includes('debian')) return 'Linux'
if (u.includes('mac') || u.includes('darwin')) return 'macOS'
return os || '—'
}
export default function DeviceCard({
id,
name,
ip,
status,
os,
lastSeen,
onConectar,
onArchivos,
onTerminal,
onInfo,
isConnecting = false,
}: DeviceCardProps) {
const statusStyle = statusConfig[status]
const osLabel = normalizeOS(os)
const handleCardClick = () => id && onInfo?.(id, name)
return (
<div
role={id && onInfo ? 'button' : undefined}
tabIndex={id && onInfo ? 0 : undefined}
onClick={handleCardClick}
onKeyDown={(e) => id && onInfo && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onInfo(id, name))}
className={cn(
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5',
'transition-all duration-200 hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20',
id && onInfo && 'cursor-pointer'
)}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-dark-200 border border-white/5">
<Monitor className="h-5 w-5 text-gray-400" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold text-gray-100 truncate">{name}</p>
<p className="text-sm text-gray-500 truncate">{ip || '—'}</p>
</div>
<span
className={cn(
'shrink-0 rounded-full px-3 py-1 text-xs font-bold',
statusStyle.className
)}
>
{statusStyle.label}
</span>
</div>
</div>
</div>
<div className="mt-4 border-t border-white/10 pt-4">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">{lastSeen}</span>
<span className="rounded-full bg-dark-200 px-2.5 py-0.5 text-xs text-gray-400 border border-white/5">
{osLabel}
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-4 gap-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => id && status === 'online' && !isConnecting && onConectar?.(id)}
disabled={status !== 'online' || isConnecting}
title={status !== 'online' ? 'Solo disponible para dispositivos en línea' : 'Conectar escritorio remoto'}
className={cn(
'flex flex-col items-center gap-1 rounded-lg py-2.5 border border-white/5',
status === 'online' && !isConnecting
? 'bg-dark-200/80 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400'
: 'bg-dark-200/50 text-gray-600 cursor-not-allowed'
)}
>
<ExternalLink className={cn('h-4 w-4', isConnecting && 'animate-pulse')} />
<span className="text-xs font-medium">{isConnecting ? 'Conectando…' : 'Conectar'}</span>
</button>
<button
type="button"
onClick={() => id && onArchivos?.(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"
>
<FolderOpen className="h-4 w-4" />
<span className="text-xs font-medium">Archivos</span>
</button>
<button
type="button"
onClick={() => id && onTerminal?.(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"
>
<Terminal className="h-4 w-4" />
<span className="text-xs font-medium">Terminal</span>
</button>
<button
type="button"
onClick={() => id && onInfo?.(id, name)}
className="flex flex-col items-center gap-1 rounded-lg bg-dark-200/80 py-2.5 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400 border border-white/5"
>
<Info className="h-4 w-4" />
<span className="text-xs font-medium">Info</span>
</button>
</div>
</div>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

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

View File

@@ -0,0 +1,50 @@
'use client'
import { Folder, FileText } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface FileItem {
id: string
name: string
type: 'folder' | 'file'
size: string | null
modifiedAt: string
}
interface FileRowProps {
file: FileItem
onFolderClick?: (name: string) => void
}
export default function FileRow({ file, onFolderClick }: FileRowProps) {
const isFolder = file.type === 'folder'
return (
<tr
className={cn(
'border-b border-white/5 transition-colors last:border-b-0',
'hover:bg-white/5 cursor-pointer',
isFolder && 'cursor-pointer'
)}
onClick={() => isFolder && onFolderClick?.(file.name)}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
{isFolder ? (
<Folder className="h-5 w-5 shrink-0 text-amber-400/90" />
) : (
<FileText className="h-5 w-5 shrink-0 text-gray-500" />
)}
<span className="font-medium text-gray-200 truncate">{file.name}</span>
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-500 font-mono">
{file.size ?? '—'}
</td>
<td className="py-3 px-4 text-sm text-gray-500">
{isFolder ? 'Carpeta' : 'Archivo'}
</td>
<td className="py-3 px-4 text-sm text-gray-500">{file.modifiedAt}</td>
</tr>
)
}

View File

@@ -0,0 +1,35 @@
'use client'
import type { FileItem } from './FileRow'
import FileRow from './FileRow'
interface FileTableProps {
files: FileItem[]
onFolderClick?: (name: string) => void
}
export default function FileTable({ files, onFolderClick }: FileTableProps) {
return (
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead className="sticky top-0 z-10 bg-dark-300/95 backdrop-blur">
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="py-3 px-4">Nombre</th>
<th className="py-3 px-4 w-28">Tamaño</th>
<th className="py-3 px-4 w-24">Tipo</th>
<th className="py-3 px-4 w-36">Fecha modificación</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<FileRow
key={file.id}
file={file}
onFolderClick={onFolderClick}
/>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import { cn } from '@/lib/utils'
import type { AppDeployment } from '@/mocks/mdmDashboardData'
interface CorporateAppRowProps {
app: AppDeployment
}
export default function CorporateAppRow({ app }: CorporateAppRowProps) {
const isIncomplete = app.deployed < app.total
return (
<div className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5">
<div className="min-w-0">
<p className="font-medium text-gray-200">{app.name}</p>
<p className="text-sm text-gray-500">v{app.version}</p>
</div>
<span
className={cn(
'shrink-0 text-sm tabular-nums',
isIncomplete ? 'text-amber-400' : 'text-gray-400'
)}
>
{app.deployed}/{app.total} dispositivos
</span>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import type { AppDeployment } from '@/mocks/mdmDashboardData'
import CorporateAppRow from './CorporateAppRow'
interface CorporateAppsListProps {
apps: AppDeployment[]
}
export default function CorporateAppsList({ apps }: CorporateAppsListProps) {
return (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Apps Corporativas
</h3>
</div>
<div className="divide-y divide-white/5">
{apps.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin apps desplegadas
</div>
) : (
apps.map((app) => (
<div key={app.id} className="px-4">
<CorporateAppRow app={app} />
</div>
))
)}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,45 @@
'use client'
import { cn } from '@/lib/utils'
type AccentColor = 'green' | 'blue' | 'cyan' | 'amber'
interface HeadwindMetricCardProps {
label: string
value: string | number
subtitle: string
accent?: AccentColor
}
const accentColors: Record<AccentColor, string> = {
green: 'text-emerald-400',
blue: 'text-blue-400',
cyan: 'text-cyan-400',
amber: 'text-amber-400',
}
export default function HeadwindMetricCard({
label,
value,
subtitle,
accent = 'cyan',
}: HeadwindMetricCardProps) {
return (
<div
className={cn(
'rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col justify-center px-5 py-6',
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
)}
>
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
{label}
</span>
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
{value}
</span>
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import type { Device } from '@/mocks/mdmDashboardData'
import DeviceItem from './DeviceItem'
interface MobileDeviceListProps {
devices: Device[]
}
export default function MobileDeviceList({ devices }: MobileDeviceListProps) {
return (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Dispositivos Móviles
</h3>
</div>
<div className="divide-y divide-white/5">
{devices.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin dispositivos
</div>
) : (
devices.map((device) => (
<div key={device.id} className="px-4">
<DeviceItem device={device} />
</div>
))
)}
</div>
</div>
)
}

View File

@@ -1,9 +1,12 @@
'use client'
import { useState } from 'react'
import { Bell, Search, User, LogOut, Settings, ChevronDown } from 'lucide-react'
import { Bell, Search, User, LogOut, Settings, ChevronDown, Menu } from 'lucide-react'
import { cn } from '@/lib/utils'
import ClientSelector from './ClientSelector'
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
export type HeaderClient = { id: string; nombre: string; codigo: string }
interface HeaderProps {
user?: {
@@ -13,17 +16,36 @@ interface HeaderProps {
rol: string
}
onLogout?: () => void
clients?: HeaderClient[]
showAllClientsOption?: boolean
onOpenSidebar?: () => void
}
export default function Header({ user, onLogout }: HeaderProps) {
export default function Header({
user,
onLogout,
clients = [],
showAllClientsOption = false,
onOpenSidebar,
}: HeaderProps) {
const [showUserMenu, setShowUserMenu] = useState(false)
const [showNotifications, setShowNotifications] = useState(false)
const { selectedClientId, setSelectedClientId } = useSelectedClient()
return (
<header className="h-16 bg-dark-400 border-b border-dark-100 flex items-center justify-between px-6">
{/* Search */}
<div className="flex items-center gap-4 flex-1">
<div className="relative w-96">
<header className="h-16 bg-dark-400 border-b border-dark-100 flex items-center justify-between gap-2 px-4 sm:px-6">
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
{onOpenSidebar != null && (
<button
type="button"
onClick={onOpenSidebar}
aria-label="Abrir menú"
className="md:hidden shrink-0 p-2 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
>
<Menu className="w-6 h-6" />
</button>
)}
<div className="relative w-full max-w-xs sm:max-w-none sm:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
@@ -31,9 +53,13 @@ export default function Header({ user, onLogout }: HeaderProps) {
className="input pl-10 bg-dark-300"
/>
</div>
{/* Client Selector */}
<ClientSelector />
<ClientSelector
clients={clients}
selectedId={selectedClientId}
onChange={setSelectedClientId}
showAll={showAllClientsOption}
/>
</div>
{/* Right section */}
@@ -79,7 +105,7 @@ export default function Header({ user, onLogout }: HeaderProps) {
/>
</div>
<div className="px-4 py-3 border-t border-dark-100">
<a href="/alertas" className="text-primary-500 text-sm hover:underline">
<a href="/alerts" className="text-primary-500 text-sm hover:underline">
Ver todas las alertas
</a>
</div>

View File

@@ -1,88 +1,113 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import {
LayoutDashboard,
Monitor,
Smartphone,
Video,
Terminal,
FolderOpen,
Gauge,
Package,
Network,
Smartphone,
AlertTriangle,
FileText,
Settings,
Users,
Building2,
ChevronLeft,
ChevronRight,
Activity,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import SidebarItem, { type BadgeType } from './SidebarItem'
import SidebarSection from './SidebarSection'
interface NavItem {
export interface SidebarMenuItem {
label: string
href: string
icon: React.ReactNode
badge?: number
badge?: { type: BadgeType; value: string | number }
}
const navItems: NavItem[] = [
export interface SidebarMenuSection {
label: string
items: SidebarMenuItem[]
}
const menuConfig: SidebarMenuSection[] = [
{
label: 'Dashboard',
href: '/',
icon: <LayoutDashboard className="w-5 h-5" />,
},
label: 'PRINCIPAL',
items: [
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard className="w-5 h-5" /> },
{
label: 'Equipos',
href: '/equipos',
label: 'Dispositivos',
href: '/devices',
icon: <Monitor className="w-5 h-5" />,
badge: { type: 'red', value: 10 },
},
{
label: 'Celulares',
href: '/celulares',
icon: <Smartphone className="w-5 h-5" />,
label: 'Sesiones',
href: '/sesiones',
icon: <Video className="w-5 h-5" />,
badge: { type: 'red', value: 4 },
},
],
},
{
label: 'Red',
href: '/red',
label: 'HERRAMIENTAS',
items: [
{ label: 'Terminal', href: '/terminal', icon: <Terminal className="w-5 h-5" /> },
{ label: 'Archivos', href: '/archivos', icon: <FolderOpen className="w-5 h-5" /> },
{ label: 'Rendimiento', href: '/rendimiento', icon: <Gauge className="w-5 h-5" /> },
{ label: 'Software', href: '/software', icon: <Package className="w-5 h-5" /> },
],
},
{
label: 'INTEGRACIONES',
items: [
{
label: 'LibreNMS',
href: '/librenms',
icon: <Network className="w-5 h-5" />,
badge: { type: 'green', value: 'OK' },
},
{
label: 'Headwind MDM',
href: '/headwind',
icon: <Smartphone className="w-5 h-5" />,
badge: { type: 'blue', value: 12 },
},
],
},
{
label: 'MONITOREO',
items: [
{
label: 'Alertas',
href: '/alertas',
href: '/alerts',
icon: <AlertTriangle className="w-5 h-5" />,
badge: { type: 'red', value: 5 },
},
{ label: 'Reportes', href: '/reportes', icon: <FileText className="w-5 h-5" /> },
],
},
{
label: 'Reportes',
href: '/reportes',
icon: <FileText className="w-5 h-5" />,
},
]
const adminItems: NavItem[] = [
{
label: 'Clientes',
href: '/clientes',
icon: <Building2 className="w-5 h-5" />,
},
{
label: 'Usuarios',
href: '/usuarios',
icon: <Users className="w-5 h-5" />,
},
{
label: 'Configuracion',
href: '/configuracion',
icon: <Settings className="w-5 h-5" />,
label: 'SISTEMA',
items: [
{ label: 'Configuracion', href: '/configuracion', icon: <Settings className="w-5 h-5" /> },
],
},
]
interface SidebarProps {
alertasActivas?: number
activeAlertsCount?: number
devicesCount?: number
sessionsCount?: number
open?: boolean
onClose?: () => void
}
export default function Sidebar({ alertasActivas = 0 }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
export default function Sidebar({ activeAlertsCount, devicesCount, sessionsCount, open = false, onClose }: SidebarProps) {
const pathname = usePathname()
const isActive = (href: string) => {
@@ -90,93 +115,87 @@ export default function Sidebar({ alertasActivas = 0 }: SidebarProps) {
return pathname.startsWith(href)
}
const items = navItems.map((item) => ({
...item,
badge: item.href === '/alertas' ? alertasActivas : undefined,
}))
const getBadgeValue = (item: SidebarMenuItem): SidebarMenuItem['badge'] => {
if (item.href === '/alerts' && activeAlertsCount !== undefined) {
if (activeAlertsCount === 0) return undefined
return { type: 'red', value: activeAlertsCount }
}
if (item.href === '/devices' && devicesCount !== undefined) {
return { type: 'red', value: devicesCount }
}
if (item.href === '/sesiones' && sessionsCount !== undefined) {
if (sessionsCount === 0) return undefined
return { type: 'red', value: sessionsCount }
}
return item.badge
}
return (
<>
{/* Mobile overlay */}
<div
role="button"
tabIndex={0}
aria-label="Cerrar menú"
onClick={onClose}
onKeyDown={(e) => e.key === 'Escape' && onClose?.()}
className={cn(
'fixed inset-0 z-30 bg-black/60 transition-opacity duration-200 md:hidden',
open ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
/>
<aside
className={cn(
'h-screen bg-dark-400 border-r border-dark-100 flex flex-col transition-all duration-300',
collapsed ? 'w-16' : 'w-64'
'fixed left-0 top-0 z-40 h-screen w-[260px] flex flex-col overflow-hidden',
'bg-gradient-to-b from-[#0f172a] to-[#111827]',
'border-r border-slate-800/80',
'transition-transform duration-200 ease-out',
'md:translate-x-0',
open ? 'translate-x-0' : '-translate-x-full'
)}
>
{/* Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-dark-100">
{!collapsed && (
<Link href="/" className="flex items-center gap-2">
<Activity className="w-8 h-8 text-primary-500" />
<span className="font-bold text-lg gradient-text">MSP Monitor</span>
<div className="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-slate-800/80 px-4">
<Link
href="/"
onClick={onClose}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-slate-700/30"
>
<Activity className="h-8 w-8 text-cyan-400" />
<span className="text-lg font-semibold text-white">MSP Monitor</span>
</Link>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="p-1.5 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
type="button"
onClick={onClose}
aria-label="Cerrar menú"
className="md:hidden p-2 rounded-lg text-slate-400 hover:bg-slate-700/30 hover:text-white transition-colors"
>
{collapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
<X className="w-6 h-6" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
{items.map((item) => (
<Link
key={item.href}
<nav className="flex-1 overflow-y-auto px-3 py-4">
{menuConfig.map((section) => (
<SidebarSection key={section.label} label={section.label}>
{section.items.map((item) => (
<SidebarItem
key={item.href + item.label}
label={item.label}
href={item.href}
className={cn(
'sidebar-link',
isActive(item.href) && 'active',
collapsed && 'justify-center px-2'
)}
title={collapsed ? item.label : undefined}
>
{item.icon}
{!collapsed && (
<>
<span className="flex-1">{item.label}</span>
{item.badge !== undefined && item.badge > 0 && (
<span className="badge badge-danger">{item.badge}</span>
)}
</>
)}
{collapsed && item.badge !== undefined && item.badge > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger rounded-full text-xs flex items-center justify-center">
{item.badge > 9 ? '9+' : item.badge}
</span>
)}
</Link>
icon={item.icon}
active={isActive(item.href)}
badge={getBadgeValue(item)}
onClick={onClose}
/>
))}
{/* Separador */}
<div className="h-px bg-dark-100 my-4" />
{/* Admin items */}
{adminItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'sidebar-link',
isActive(item.href) && 'active',
collapsed && 'justify-center px-2'
)}
title={collapsed ? item.label : undefined}
>
{item.icon}
{!collapsed && <span className="flex-1">{item.label}</span>}
</Link>
</SidebarSection>
))}
</nav>
{/* Footer */}
{!collapsed && (
<div className="p-4 border-t border-dark-100">
<div className="text-xs text-gray-500 text-center">
MSP Monitor v1.0.0
<div className="border-t border-slate-800/80 px-4 py-3 shrink-0">
<p className="text-center text-xs text-slate-500">MSP Monitor v1.0.0</p>
</div>
</div>
)}
</aside>
</>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import Link from 'next/link'
import { cn } from '@/lib/utils'
export type BadgeType = 'red' | 'blue' | 'green'
export interface SidebarItemProps {
label: string
href: string
icon: React.ReactNode
active?: boolean
badge?: {
type: BadgeType
value: string | number
}
onClick?: () => void
}
const badgeStyles: Record<BadgeType, string> = {
red: 'bg-red-500/90 text-white',
blue: 'bg-blue-500/90 text-white',
green: 'bg-emerald-500/90 text-white',
}
export default function SidebarItem({
label,
href,
icon,
active,
badge,
onClick,
}: SidebarItemProps) {
const isPill = badge?.type === 'green' && typeof badge.value === 'string'
return (
<Link
href={href}
onClick={onClick}
className={cn(
'flex items-center gap-3 rounded-lg px-4 py-2.5 transition-all duration-200',
active
? 'bg-gradient-to-r from-cyan-600/30 to-blue-600/20 text-white shadow-[0_0_20px_-5px_rgba(6,182,212,0.25)]'
: 'text-slate-400 hover:bg-slate-700/50 hover:text-slate-200'
)}
>
<span
className={cn(
'flex shrink-0',
active ? 'text-cyan-300' : 'text-slate-500'
)}
>
{icon}
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{badge != null && (
<span
className={cn(
'flex shrink-0 items-center justify-center text-xs font-bold',
isPill ? 'rounded-full px-2 py-0.5' : 'h-5 min-w-[1.25rem] rounded-full px-1.5',
badgeStyles[badge.type]
)}
>
{badge.value}
</span>
)}
</Link>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import { cn } from '@/lib/utils'
interface SidebarSectionProps {
label: string
children: React.ReactNode
className?: string
}
export default function SidebarSection({
label,
children,
className,
}: SidebarSectionProps) {
return (
<div className={cn('mt-6 first:mt-4', className)}>
<p className="mb-2 px-4 text-xs font-medium uppercase tracking-wider text-slate-500">
{label}
</p>
<div className="space-y-0.5">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import { cn } from '@/lib/utils'
export type AlertSeverity = 'critical' | 'warning' | 'info'
export interface SnmpAlert {
id: string
title: string
detail: string
severity: AlertSeverity
}
const severityStyles: Record<AlertSeverity, string> = {
critical: 'text-red-400',
warning: 'text-yellow-400',
info: 'text-blue-400',
}
interface AlertItemProps {
alert: SnmpAlert
}
export default function AlertItem({ alert }: AlertItemProps) {
return (
<div className="border-b border-white/5 py-4 last:border-b-0">
<p className={cn('font-medium', severityStyles[alert.severity])}>
{alert.title}
</p>
<p className="mt-1 text-sm text-gray-500">{alert.detail}</p>
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import type { SnmpAlert } from './AlertItem'
import AlertItem from './AlertItem'
interface AlertListProps {
alerts: SnmpAlert[]
}
export default function AlertList({ alerts }: AlertListProps) {
return (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Alertas SNMP Recientes
</h3>
</div>
<div className="px-4">
{alerts.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin alertas
</div>
) : (
alerts.map((alert) => (
<AlertItem key={alert.id} alert={alert} />
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import type { NetworkDevice } from './DeviceRow'
import DeviceRow from './DeviceRow'
interface DeviceListProps {
devices: NetworkDevice[]
}
export default function DeviceList({ devices }: DeviceListProps) {
return (
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
<div className="border-b border-white/10 px-4 py-3">
<h3 className="text-sm font-medium text-gray-400">
Dispositivos de Red
</h3>
</div>
<div className="divide-y divide-white/5">
{devices.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Sin dispositivos
</div>
) : (
devices.map((device) => (
<div key={device.id} className="px-4">
<DeviceRow device={device} />
</div>
))
)}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,45 @@
'use client'
import { cn } from '@/lib/utils'
type AccentColor = 'green' | 'cyan' | 'yellow' | 'blue'
interface LibrenmsMetricCardProps {
label: string
value: string | number
subtitle: string
accent?: AccentColor
}
const accentColors: Record<AccentColor, string> = {
green: 'text-emerald-400',
cyan: 'text-cyan-400',
yellow: 'text-amber-400',
blue: 'text-blue-400',
}
export default function LibrenmsMetricCard({
label,
value,
subtitle,
accent = 'cyan',
}: LibrenmsMetricCardProps) {
return (
<div
className={cn(
'rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col justify-center px-5 py-6',
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
)}
>
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
{label}
</span>
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
{value}
</span>
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import { cn } from '@/lib/utils'
export interface LibrenmsNodeItem {
id: string
name: string
ip: string
type: string
status: 'up' | 'down' | 'warning'
lastUpdate: string
}
interface LibrenmsRowProps {
item: LibrenmsNodeItem
}
const statusConfig = {
up: { label: 'En línea', dot: 'bg-emerald-500' },
down: { label: 'Fuera de línea', dot: 'bg-red-500' },
warning: { label: 'Alerta', dot: 'bg-amber-500' },
}
export default function LibrenmsRow({ item }: LibrenmsRowProps) {
const status = statusConfig[item.status]
return (
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5 cursor-pointer">
<td className="py-3 px-4">
<span className="font-medium text-gray-200">{item.name}</span>
</td>
<td className="py-3 px-4 font-mono text-sm text-gray-400">
{item.ip}
</td>
<td className="py-3 px-4">
<span className="rounded-full bg-cyan-500/10 px-3 py-1 text-xs text-cyan-400">
{item.type}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className={cn('h-2 w-2 rounded-full', status.dot)} />
<span className="text-sm text-gray-400">{status.label}</span>
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
</tr>
)
}

View File

@@ -0,0 +1,26 @@
'use client'
import { cn } from '@/lib/utils'
export type DeviceStatus = 'online' | 'warning' | 'critical'
interface StatusBadgeProps {
status: DeviceStatus
label: string
}
const statusConfig: Record<DeviceStatus, { dot: string; text: string }> = {
online: { dot: 'bg-green-500', text: 'text-green-400' },
warning: { dot: 'bg-yellow-500', text: 'text-yellow-400' },
critical: { dot: 'bg-red-500', text: 'text-red-400' },
}
export default function StatusBadge({ status, label }: StatusBadgeProps) {
const config = statusConfig[status]
return (
<span className={cn('inline-flex items-center gap-1.5 text-sm', config.text)}>
<span className={cn('h-2 w-2 shrink-0 rounded-full', config.dot)} />
{label}
</span>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { cn } from '@/lib/utils'
interface LineChartProps {
className?: string
height?: number
data?: number[]
}
export default function LineChart({
className,
height = 160,
data = [],
}: LineChartProps) {
const points = data.length >= 2
? data
.map((v, i) => {
const x = (i / Math.max(1, data.length - 1)) * 100
const y = 100 - Math.min(100, Math.max(0, v))
return `${x},${y}`
})
.join(' ')
: '0,100 100,100'
return (
<div
className={cn(
'rounded-lg bg-dark-200/80 border border-white/5 overflow-hidden',
className
)}
style={{ height }}
>
{data.length >= 2 ? (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="w-full h-full text-cyan-500/30"
>
<polyline
fill="none"
stroke="currentColor"
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
className="transition-all duration-500"
/>
</svg>
) : (
<div className="flex h-full items-center justify-center text-gray-600 text-sm">
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import { cn } from '@/lib/utils'
import LineChart from './LineChart'
export interface MetricCardFooterRow {
label: string
value: string
}
interface MetricCardProps {
title: string
value: string
valueSuffix?: string
footerStats: MetricCardFooterRow[]
chartData?: number[]
highUsage?: boolean
}
export default function MetricCard({
title,
value,
valueSuffix,
footerStats,
chartData,
highUsage = false,
}: MetricCardProps) {
return (
<div
className={cn(
'flex flex-col rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'transition-all duration-200 hover:border-white/20'
)}
>
<div className="flex shrink-0 items-center justify-between px-4 py-3 border-b border-white/5">
<span className="text-sm font-medium text-gray-400">{title}</span>
<span
className={cn(
'font-mono text-lg font-semibold tabular-nums transition-colors duration-300',
highUsage ? 'text-red-400' : 'text-cyan-400'
)}
>
{value}
{valueSuffix != null && (
<span className="text-sm font-normal text-gray-500 ml-0.5">
{valueSuffix}
</span>
)}
</span>
</div>
<div className="shrink-0 px-4 pt-3">
<LineChart height={150} data={chartData ?? []} />
</div>
<div className="shrink-0 space-y-1.5 px-4 py-3 border-t border-white/5">
{footerStats.map((row) => (
<div
key={row.label}
className="flex justify-between text-xs"
>
<span className="text-gray-500">{row.label}</span>
<span className="font-mono text-gray-400 tabular-nums">
{row.value}
</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
import { cn } from '@/lib/utils'
export interface ProcessItem {
id: string
name: string
pid: number
cpu: number
memory: string
state: string
}
interface ProcessRowProps {
process: ProcessItem
}
export default function ProcessRow({ process }: ProcessRowProps) {
return (
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
<td className="py-2.5 px-4 text-sm text-gray-200 truncate max-w-[200px]">
{process.name}
</td>
<td className="py-2.5 px-4 font-mono text-sm text-gray-400 tabular-nums">
{process.pid}
</td>
<td className="py-2.5 px-4 font-mono text-sm text-cyan-400 tabular-nums">
{process.cpu}%
</td>
<td className="py-2.5 px-4 font-mono text-sm text-gray-400">
{process.memory}
</td>
<td className="py-2.5 px-4 text-sm text-gray-500">{process.state}</td>
</tr>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import type { ProcessItem } from './ProcessRow'
import ProcessRow from './ProcessRow'
interface ProcessTableProps {
processes: ProcessItem[]
noDevice?: boolean
}
export default function ProcessTable({ processes, noDevice }: ProcessTableProps) {
if (noDevice) {
return (
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
<div className="px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-medium text-gray-400">
Procesos Activos (Top 10 por CPU)
</h3>
</div>
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
Selecciona un dispositivo
</div>
</div>
)
}
return (
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
<div className="px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-medium text-gray-400">
Procesos Activos (Top 10 por CPU)
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="py-3 px-4">Proceso</th>
<th className="py-3 px-4 w-20">PID</th>
<th className="py-3 px-4 w-24">CPU %</th>
<th className="py-3 px-4 w-24">Memoria</th>
<th className="py-3 px-4 w-24">Estado</th>
</tr>
</thead>
<tbody>
{processes.length === 0 ? (
<tr>
<td
colSpan={5}
className="py-8 text-center text-sm text-gray-500"
>
Sin datos
</td>
</tr>
) : (
processes.map((p) => (
<ProcessRow key={p.id} process={p} />
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react'
const STORAGE_KEY = 'msp-selected-client-id'
type SelectedClientContextValue = {
selectedClientId: string | null
setSelectedClientId: (id: string | null) => void
}
const SelectedClientContext = createContext<SelectedClientContextValue | null>(null)
export function SelectedClientProvider({ children }: { children: ReactNode }) {
const [selectedClientId, setState] = useState<string | null>(null)
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
try {
const stored = typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
const legacy = typeof window !== 'undefined' ? localStorage.getItem('msp-selected-cliente-id') : null
const value = stored || legacy || null
if (value) setState(value)
if (legacy && !stored && typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, value!)
localStorage.removeItem('msp-selected-cliente-id')
}
} finally {
setHydrated(true)
}
}, [])
const setSelectedClientId = useCallback((id: string | null) => {
setState(id)
try {
if (typeof window !== 'undefined') {
if (id) localStorage.setItem(STORAGE_KEY, id)
else localStorage.removeItem(STORAGE_KEY)
}
} catch {
// ignore
}
}, [])
const value: SelectedClientContextValue = hydrated
? { selectedClientId, setSelectedClientId }
: { selectedClientId: null, setSelectedClientId }
return (
<SelectedClientContext.Provider value={value}>
{children}
</SelectedClientContext.Provider>
)
}
export function useSelectedClient() {
const ctx = useContext(SelectedClientContext)
if (!ctx) {
throw new Error('useSelectedClient must be used within SelectedClientProvider')
}
return ctx
}

View File

@@ -0,0 +1,46 @@
'use client'
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import superjson from 'superjson'
import { trpc } from '@/lib/trpc-client'
function getBaseUrl() {
if (typeof window !== 'undefined') return ''
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
return `http://localhost:${process.env.PORT ?? 3000}`
}
export default function TrpcProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
},
},
})
)
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
fetch(url, options) {
return fetch(url, { ...options, credentials: 'include' })
},
}),
],
transformer: superjson,
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}

View File

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

View File

@@ -0,0 +1,62 @@
'use client'
import { cn } from '@/lib/utils'
import { FileText, FileSpreadsheet } from 'lucide-react'
interface ReportCardProps {
title: string
description: string
onExportPdf: () => void
onExportExcel: () => void
loading?: boolean
}
export default function ReportCard({
title,
description,
onExportPdf,
onExportExcel,
loading = false,
}: ReportCardProps) {
const btnBase =
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50'
const btnPrimary =
'bg-cyan-600 text-white hover:bg-cyan-500'
const btnOutlined =
'border border-white/10 text-gray-300 hover:bg-white/5 hover:text-gray-200'
return (
<div
className={cn(
'rounded-xl border border-white/10 overflow-hidden',
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col'
)}
>
<div className="border-b border-white/10 px-4 py-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-2 text-sm text-gray-400">{description}</p>
</div>
<div className="flex flex-wrap items-center gap-2 p-4">
<button
type="button"
onClick={onExportPdf}
disabled={loading}
className={cn(btnBase, btnPrimary)}
>
<FileText className="h-4 w-4" />
Exportar PDF
</button>
<button
type="button"
onClick={onExportExcel}
disabled={loading}
className={cn(btnBase, btnOutlined)}
>
<FileSpreadsheet className="h-4 w-4" />
Excel
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { Monitor, Terminal, FolderOpen } from 'lucide-react'
import { cn } from '@/lib/utils'
export type SessionTypeLabel = 'Escritorio Remoto' | 'Terminal' | 'Archivos'
export interface SessionCardProps {
id: string
deviceName: string
userEmail: string
sessionType: SessionTypeLabel
duration: string
onEnd?: (id: string) => void
isEnding?: boolean
}
const typeConfig: Record<
SessionTypeLabel,
{ icon: React.ReactNode; bgClass: string }
> = {
'Escritorio Remoto': {
icon: <Monitor className="h-5 w-5" />,
bgClass: 'bg-cyan-500/15 text-cyan-400',
},
Terminal: {
icon: <Terminal className="h-5 w-5" />,
bgClass: 'bg-emerald-500/15 text-emerald-400',
},
Archivos: {
icon: <FolderOpen className="h-5 w-5" />,
bgClass: 'bg-amber-500/15 text-amber-400',
},
}
export default function SessionCard({
id,
deviceName,
userEmail,
sessionType,
duration,
onEnd,
isEnding,
}: SessionCardProps) {
const config = typeConfig[sessionType]
return (
<div
className={cn(
'flex flex-col gap-4 rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5 transition-all duration-200',
'sm:flex-row sm:items-center sm:justify-between',
'hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20'
)}
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<div
className={cn(
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
config.bgClass
)}
>
{config.icon}
</div>
<div className="min-w-0">
<p className="text-lg font-semibold text-gray-100 truncate">
{deviceName}
</p>
<p className="text-sm text-gray-500 truncate">{userEmail}</p>
</div>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<span className="font-mono text-sm font-medium text-cyan-400 tabular-nums">
{duration}
</span>
<span className="text-xs text-gray-500">{sessionType}</span>
</div>
<div className="shrink-0 sm:pl-4">
<button
type="button"
onClick={() => onEnd?.(id)}
disabled={isEnding}
className={cn(
'rounded-lg border border-red-500/60 px-4 py-2 text-sm font-medium text-red-400',
'transition-colors hover:bg-red-500/15 hover:border-red-500/80',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isEnding ? 'Terminando…' : 'Terminar'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { cn } from '@/lib/utils'
export interface SoftwareItem {
id: string
name: string
version: string
vendor: string
installations: number
lastUpdate: string
licensed?: boolean
needsUpdate?: boolean
}
interface SoftwareRowProps {
item: SoftwareItem
}
export default function SoftwareRow({ item }: SoftwareRowProps) {
return (
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-200">{item.name}</span>
{item.licensed && (
<span className="rounded bg-cyan-500/20 px-1.5 py-0.5 text-xs text-cyan-400">
Licencia
</span>
)}
{item.needsUpdate && (
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-400">
Actualizar
</span>
)}
</div>
</td>
<td className="py-3 px-4 font-mono text-sm text-gray-400">
{item.version}
</td>
<td className="py-3 px-4 text-sm text-gray-400">{item.vendor}</td>
<td className="py-3 px-4 font-mono text-sm text-cyan-400 tabular-nums">
{item.installations}
</td>
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
</tr>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import type { SoftwareItem } from './SoftwareRow'
import SoftwareRow from './SoftwareRow'
interface SoftwareTableProps {
items: SoftwareItem[]
}
export default function SoftwareTable({ items }: SoftwareTableProps) {
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wide text-gray-400">
<th className="py-3 px-4">Nombre</th>
<th className="py-3 px-4 w-40">Versión</th>
<th className="py-3 px-4 w-48">Editor</th>
<th className="py-3 px-4 w-28">Instalaciones</th>
<th className="py-3 px-4 w-36">Última actualización</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td
colSpan={5}
className="py-12 text-center text-sm text-gray-500"
>
No hay resultados
</td>
</tr>
) : (
items.map((item) => (
<SoftwareRow key={item.id} item={item} />
))
)}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { cn } from '@/lib/utils'
interface SummaryMetricCardProps {
value: number
label: string
className?: string
}
export default function SummaryMetricCard({
value,
label,
className,
}: SummaryMetricCardProps) {
return (
<div
className={cn(
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
'flex flex-col items-center justify-center py-6 px-4',
'transition-all duration-200 hover:border-white/20',
className
)}
>
<span className="text-4xl font-bold text-cyan-400 tabular-nums">
{value}
</span>
<span className="mt-2 text-sm text-gray-400">{label}</span>
</div>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
export interface QuickCommandsProps {
onSelectCommand: (command: string) => void
}
const COMMANDS = [
'systeminfo',
'ipconfig',
'tasklist',
'netstat',
'CPU Info',
'RAM Info',
'dir C:\\',
'hostname',
]
export default function QuickCommands({ onSelectCommand }: QuickCommandsProps) {
return (
<section className="space-y-3">
<h3 className="text-sm font-medium text-gray-400">Comandos Rápidos</h3>
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
{COMMANDS.map((cmd) => (
<button
key={cmd}
type="button"
onClick={() => onSelectCommand(cmd)}
className="shrink-0 rounded-lg border border-white/10 bg-dark-300/80 px-4 py-2 font-mono text-xs text-gray-300 transition-colors hover:border-cyan-500/30 hover:bg-dark-200 hover:text-cyan-400"
>
{cmd}
</button>
))}
</div>
</section>
)
}

View File

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

4
src/lib/trpc-client.ts Normal file
View File

@@ -0,0 +1,4 @@
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/trpc/routers'
export const trpc = createTRPCReact<AppRouter>()

View File

@@ -24,6 +24,14 @@ export function formatUptime(seconds: number): string {
return `${minutes}m`
}
export function formatDurationSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const pad = (n: number) => n.toString().padStart(2, '0')
return `${pad(h)}:${pad(m)}:${pad(s)}`
}
export function formatDate(date: Date | string): string {
const d = new Date(date)
return d.toLocaleDateString('es-MX', {
@@ -81,6 +89,21 @@ export function getStatusBgColor(status: string): string {
}
}
export function getStatusBorderColor(status: string): string {
switch (status.toUpperCase()) {
case 'ONLINE':
return 'border-success/50'
case 'OFFLINE':
return 'border-gray-500/40'
case 'ALERTA':
return 'border-danger/50'
case 'MANTENIMIENTO':
return 'border-warning/50'
default:
return 'border-dark-100'
}
}
export function getSeverityColor(severity: string): string {
switch (severity.toUpperCase()) {
case 'CRITICAL':

View 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' },
]

View 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
)
})
}

View File

@@ -0,0 +1,58 @@
export type DeviceStatus = 'online' | 'offline' | 'kiosk'
export interface Device {
id: string
name: string
androidVersion: string
batteryPercent: number
status: DeviceStatus
}
export interface AppDeployment {
id: string
name: string
version: string
deployed: number
total: number
}
export interface DashboardStats {
totalAndroidDevices: number
deployedApps: number
activePolicies: number
averageBatteryPercent: number
}
export const MOCK_DASHBOARD_STATS: DashboardStats = {
totalAndroidDevices: 12,
deployedApps: 8,
activePolicies: 5,
averageBatteryPercent: 78,
}
export const MOCK_DEVICES: Device[] = [
{ id: '1', name: 'Samsung Galaxy A54 Ventas01', androidVersion: '14', batteryPercent: 92, status: 'online' },
{ id: '2', name: 'Samsung Galaxy A54 Ventas02', androidVersion: '14', batteryPercent: 45, status: 'online' },
{ id: '3', name: 'Xiaomi Redmi Note 12 Almacén', androidVersion: '13', batteryPercent: 100, status: 'kiosk' },
{ id: '4', name: 'Motorola Moto G Reparto01', androidVersion: '13', batteryPercent: 12, status: 'offline' },
{ id: '5', name: 'Samsung Galaxy A34 Oficina', androidVersion: '14', batteryPercent: 78, status: 'online' },
]
export const MOCK_APP_DEPLOYMENTS: AppDeployment[] = [
{ id: '1', name: 'App Corporativa Ventas', version: '2.1.0', deployed: 12, total: 12 },
{ id: '2', name: 'Headwind Kiosk', version: '1.4.2', deployed: 10, total: 12 },
{ id: '3', name: 'Authenticator', version: '6.2.1', deployed: 12, total: 12 },
{ id: '4', name: 'Microsoft Teams', version: '1416/1.0.0', deployed: 8, total: 12 },
]
export function getMdmDashboardData(): {
stats: DashboardStats
devices: Device[]
appDeployments: AppDeployment[]
} {
return {
stats: MOCK_DASHBOARD_STATS,
devices: MOCK_DEVICES,
appDeployments: MOCK_APP_DEPLOYMENTS,
}
}

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

@@ -0,0 +1,100 @@
export interface ReportFilters {
desde: string
hasta: string
}
export interface InventoryReportItem {
id: string
nombre: string
tipo: string
cliente: string
ip: string
so: string
estado: string
}
export interface InventoryReport {
periodo: { desde: string; hasta: string }
total: number
items: InventoryReportItem[]
}
export interface ResourceUsageReportDevice {
dispositivo: string
cliente: string
cpuPromedio: number
memoriaPromedio: number
redPromedioMB: number
}
export interface ResourceUsageReport {
periodo: { desde: string; hasta: string }
dispositivos: ResourceUsageReportDevice[]
}
export interface AlertsReportItem {
id: string
fecha: string
titulo: string
severidad: string
estado: string
dispositivo: string
resueltoEn: string | null
}
export interface AlertsReport {
periodo: { desde: string; hasta: string }
total: number
items: AlertsReportItem[]
}
function toDateStr(d: Date): string {
return d.toISOString().split('T')[0]
}
// TODO: replace with trpc.reportes.inventario (and optional date filter if backend supports it)
export async function fetchInventoryReport(
startDate: Date,
endDate: Date
): Promise<InventoryReport> {
await new Promise((r) => setTimeout(r, 300))
return {
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
total: 42,
items: [
{ id: '1', nombre: 'PC-Oficina-01', tipo: 'PC', cliente: 'Cliente A', ip: '192.168.1.10', so: 'Windows 11', estado: 'Activo' },
{ id: '2', nombre: 'LAPTOP-Ventas-02', tipo: 'LAPTOP', cliente: 'Cliente A', ip: '192.168.1.22', so: 'Windows 11', estado: 'Activo' },
{ id: '3', nombre: 'SRV-DC-01', tipo: 'SERVIDOR', cliente: 'Cliente B', ip: '10.0.0.5', so: 'Windows Server 2022', estado: 'Activo' },
],
}
}
export async function fetchResourceUsageReport(
startDate: Date,
endDate: Date
): Promise<ResourceUsageReport> {
await new Promise((r) => setTimeout(r, 300))
return {
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
dispositivos: [
{ dispositivo: 'PC-Oficina-01', cliente: 'Cliente A', cpuPromedio: 24, memoriaPromedio: 62, redPromedioMB: 120 },
{ dispositivo: 'LAPTOP-Ventas-02', cliente: 'Cliente A', cpuPromedio: 18, memoriaPromedio: 45, redPromedioMB: 85 },
{ dispositivo: 'SRV-DC-01', cliente: 'Cliente B', cpuPromedio: 12, memoriaPromedio: 78, redPromedioMB: 340 },
],
}
}
export async function fetchAlertsReport(
startDate: Date,
endDate: Date
): Promise<AlertsReport> {
await new Promise((r) => setTimeout(r, 300))
return {
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
total: 28,
items: [
{ id: '1', fecha: '2024-01-15T10:30:00Z', titulo: 'CPU alto', severidad: 'CRITICAL', estado: 'RESUELTA', dispositivo: 'PC-Oficina-01', resueltoEn: '2024-01-15T10:45:00Z' },
{ id: '2', fecha: '2024-01-15T09:00:00Z', titulo: 'Disco lleno', severidad: 'WARNING', estado: 'RESUELTA', dispositivo: 'SRV-DC-01', resueltoEn: '2024-01-15T11:00:00Z' },
],
}
}

View File

@@ -1,5 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { TipoDispositivo } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { HeadwindClient } from '@/server/services/headwind/client'
@@ -19,7 +20,7 @@ export const celularesRouter = router({
const { clienteId, estado, search, page = 1, limit = 20 } = input || {}
const where = {
tipo: { in: ['CELULAR', 'TABLET'] as const },
tipo: { in: ['CELULAR', 'TABLET'] as TipoDispositivo[] },
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),

View File

@@ -162,6 +162,7 @@ export const clientesRouter = router({
dispositivosAlerta,
alertasActivas,
alertasCriticas,
sesionesActivas,
] = await Promise.all([
ctx.prisma.dispositivo.count({ where }),
ctx.prisma.dispositivo.count({ where: { ...where, estado: 'ONLINE' } }),
@@ -173,6 +174,12 @@ export const clientesRouter = router({
ctx.prisma.alerta.count({
where: { ...where, estado: 'ACTIVA', severidad: 'CRITICAL' },
}),
ctx.prisma.sesionRemota.count({
where: {
finalizadaEn: null,
...(clienteId ? { dispositivo: { clienteId } } : {}),
},
}),
])
return {
@@ -182,7 +189,7 @@ export const clientesRouter = router({
dispositivosAlerta,
alertasActivas,
alertasCriticas,
sesionesActivas: 0, // TODO: implementar
sesionesActivas,
}
}),

View File

@@ -1,9 +1,127 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { TipoDispositivo } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { MeshCentralClient } from '@/server/services/meshcentral/client'
const tipoDispositivoSchema = z.enum([
'PC', 'LAPTOP', 'SERVIDOR', 'CELULAR', 'TABLET', 'ROUTER', 'SWITCH',
'FIREWALL', 'AP', 'IMPRESORA', 'OTRO',
])
const estadoDispositivoSchema = z.enum([
'ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO',
])
export const equiposRouter = router({
create: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
tipo: tipoDispositivoSchema,
nombre: z.string().min(1, 'Nombre requerido'),
descripcion: z.string().optional(),
ubicacionId: z.string().optional().nullable(),
estado: estadoDispositivoSchema.optional(),
ip: z.string().optional().nullable(),
mac: z.string().optional().nullable(),
sistemaOperativo: z.string().optional().nullable(),
versionSO: z.string().optional().nullable(),
fabricante: z.string().optional().nullable(),
modelo: z.string().optional().nullable(),
serial: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId ?? input.clienteId
if (!clienteId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Se requiere cliente (seleccione un cliente o use un usuario con cliente asignado)',
})
}
if (ctx.user.clienteId && ctx.user.clienteId !== clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'No puede crear dispositivos para otro cliente' })
}
return ctx.prisma.dispositivo.create({
data: {
clienteId,
tipo: input.tipo as TipoDispositivo,
nombre: input.nombre.trim(),
descripcion: input.descripcion?.trim() || null,
ubicacionId: input.ubicacionId || null,
estado: (input.estado as 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO') ?? 'DESCONOCIDO',
ip: input.ip?.trim() || null,
mac: input.mac?.trim() || null,
sistemaOperativo: input.sistemaOperativo?.trim() || null,
versionSO: input.versionSO?.trim() || null,
fabricante: input.fabricante?.trim() || null,
modelo: input.modelo?.trim() || null,
serial: input.serial?.trim() || null,
},
include: {
cliente: { select: { id: true, nombre: true } },
ubicacion: { select: { id: true, nombre: true } },
},
})
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
tipo: tipoDispositivoSchema.optional(),
nombre: z.string().min(1, 'Nombre requerido').optional(),
descripcion: z.string().optional().nullable(),
ubicacionId: z.string().optional().nullable(),
estado: estadoDispositivoSchema.optional(),
ip: z.string().optional().nullable(),
mac: z.string().optional().nullable(),
sistemaOperativo: z.string().optional().nullable(),
versionSO: z.string().optional().nullable(),
fabricante: z.string().optional().nullable(),
modelo: z.string().optional().nullable(),
serial: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.dispositivo.findUnique({
where: { id: input.id },
select: { clienteId: true },
})
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Dispositivo no encontrado' })
}
if (ctx.user.clienteId && ctx.user.clienteId !== existing.clienteId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'No puede editar este dispositivo' })
}
const data: Record<string, unknown> = {}
if (input.tipo !== undefined) data.tipo = input.tipo as TipoDispositivo
if (input.nombre !== undefined) data.nombre = input.nombre.trim()
if (input.descripcion !== undefined) data.descripcion = input.descripcion?.trim() || null
if (input.ubicacionId !== undefined) data.ubicacionId = input.ubicacionId || null
if (input.estado !== undefined) data.estado = input.estado
if (input.ip !== undefined) data.ip = input.ip?.trim() || null
if (input.mac !== undefined) data.mac = input.mac?.trim() || null
if (input.sistemaOperativo !== undefined) data.sistemaOperativo = input.sistemaOperativo?.trim() || null
if (input.versionSO !== undefined) data.versionSO = input.versionSO?.trim() || null
if (input.fabricante !== undefined) data.fabricante = input.fabricante?.trim() || null
if (input.modelo !== undefined) data.modelo = input.modelo?.trim() || null
if (input.serial !== undefined) data.serial = input.serial?.trim() || null
return ctx.prisma.dispositivo.update({
where: { id: input.id },
data,
include: {
cliente: { select: { id: true, nombre: true } },
ubicacion: { select: { id: true, nombre: true } },
software: { orderBy: { nombre: 'asc' }, take: 100 },
},
})
}),
// Listar equipos de computo (PC, laptop, servidor)
list: protectedProcedure
.input(
@@ -12,18 +130,22 @@ export const equiposRouter = router({
tipo: z.enum(['PC', 'LAPTOP', 'SERVIDOR']).optional(),
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
search: z.string().optional(),
sistemaOperativo: z.string().optional(),
page: z.number().default(1),
limit: z.number().default(20),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
const { clienteId, tipo, estado, search, sistemaOperativo, page = 1, limit = 20 } = input || {}
const where = {
tipo: tipo ? { equals: tipo } : { in: ['PC', 'LAPTOP', 'SERVIDOR'] as const },
tipo: tipo ? { equals: tipo } : { in: ['PC', 'LAPTOP', 'SERVIDOR'] as TipoDispositivo[] },
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
...(clienteId ? { clienteId } : {}),
...(estado ? { estado } : {}),
...(sistemaOperativo ? {
sistemaOperativo: { contains: sistemaOperativo, mode: 'insensitive' as const },
} : {}),
...(search ? {
OR: [
{ nombre: { contains: search, mode: 'insensitive' as const } },

View File

@@ -5,6 +5,7 @@ import { equiposRouter } from './equipos.router'
import { celularesRouter } from './celulares.router'
import { redRouter } from './red.router'
import { alertasRouter } from './alertas.router'
import { sesionesRouter } from './sesiones.router'
import { reportesRouter } from './reportes.router'
import { usuariosRouter } from './usuarios.router'
import { configuracionRouter } from './configuracion.router'
@@ -16,6 +17,7 @@ export const appRouter = router({
celulares: celularesRouter,
red: redRouter,
alertas: alertasRouter,
sesiones: sesionesRouter,
reportes: reportesRouter,
usuarios: usuariosRouter,
configuracion: configuracionRouter,

View File

@@ -1,5 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { TipoDispositivo } from '@prisma/client'
import { router, protectedProcedure } from '../trpc'
import { LibreNMSClient } from '@/server/services/librenms/client'
@@ -19,7 +20,7 @@ export const redRouter = router({
.query(async ({ ctx, input }) => {
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
const tiposRed = ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const
const tiposRed: TipoDispositivo[] = ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']
const where = {
tipo: tipo ? { equals: tipo } : { in: tiposRed },
@@ -289,7 +290,7 @@ export const redRouter = router({
const clienteId = ctx.user.clienteId || input.clienteId
const where = {
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const },
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as TipoDispositivo[] },
...(clienteId ? { clienteId } : {}),
}

View File

@@ -0,0 +1,47 @@
import { z } from 'zod'
import { router, protectedProcedure } from '../trpc'
export const sesionesRouter = router({
list: protectedProcedure
.input(
z.object({
clienteId: z.string().optional(),
limit: z.number().default(50),
}).optional()
)
.query(async ({ ctx, input }) => {
const { clienteId, limit = 50 } = input || {}
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
const where = {
finalizadaEn: null,
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
}
const sessions = await ctx.prisma.sesionRemota.findMany({
where,
include: {
usuario: { select: { id: true, email: true, nombre: true } },
dispositivo: { select: { id: true, nombre: true, clienteId: true } },
},
orderBy: { iniciadaEn: 'desc' },
take: limit,
})
return { sessions }
}),
count: protectedProcedure
.input(z.object({ clienteId: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const clienteId = ctx.user.clienteId || input?.clienteId
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
const where = {
finalizadaEn: null,
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
}
return ctx.prisma.sesionRemota.count({ where })
}),
})