Compare commits
11 Commits
main
...
DevEsteban
| Author | SHA1 | Date | |
|---|---|---|---|
| d999cf6298 | |||
| bd9bffb57c | |||
| 4235f640d9 | |||
| 1761dcdfe8 | |||
| 43d2ed9011 | |||
| 7f6ada6d39 | |||
| 9a8815d4f5 | |||
| 20982aa077 | |||
| 5d698490e2 | |||
| 32314228c4 | |||
| d88baefdf9 |
@@ -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
3764
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
0
scripts/setup.sh
Normal file → Executable file
61
src/app/(dashboard)/alerts/page.tsx
Normal file
61
src/app/(dashboard)/alerts/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
src/app/(dashboard)/archivos/page.tsx
Normal file
147
src/app/(dashboard)/archivos/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import FileExplorerContainer from '@/components/files/FileExplorerContainer'
|
||||
import type { FileItem } from '@/components/files/FileRow'
|
||||
|
||||
const MOCK_FILES_BY_PATH: Record<string, FileItem[]> = {
|
||||
'C:\\': [
|
||||
{ id: '1', name: 'Documents', type: 'folder', size: null, modifiedAt: '2026-02-15' },
|
||||
{ id: '2', name: 'Windows', type: 'folder', size: null, modifiedAt: '2026-02-10' },
|
||||
{ id: '3', name: 'Users', type: 'folder', size: null, modifiedAt: '2026-02-14' },
|
||||
{ id: '4', name: 'archivo.txt', type: 'file', size: '2.4 KB', modifiedAt: '2026-02-12' },
|
||||
{ id: '5', name: 'config.ini', type: 'file', size: '1.1 KB', modifiedAt: '2026-02-11' },
|
||||
],
|
||||
'C:\\Documents': [
|
||||
{ id: 'd1', name: '..', type: 'folder', size: null, modifiedAt: '—' },
|
||||
{ id: 'd2', name: 'Subcarpeta', type: 'folder', size: null, modifiedAt: '2026-02-14' },
|
||||
{ id: 'd3', name: 'readme.txt', type: 'file', size: '512 B', modifiedAt: '2026-02-13' },
|
||||
{ id: 'd4', name: 'reporte.pdf', type: 'file', size: '245 KB', modifiedAt: '2026-02-15' },
|
||||
],
|
||||
'C:\\Windows': [
|
||||
{ id: 'w1', name: '..', type: 'folder', size: null, modifiedAt: '—' },
|
||||
{ id: 'w2', name: 'System32', type: 'folder', size: null, modifiedAt: '2026-02-10' },
|
||||
{ id: 'w3', name: 'Temp', type: 'folder', size: null, modifiedAt: '2026-02-15' },
|
||||
],
|
||||
'C:\\Users': [
|
||||
{ id: 'u1', name: '..', type: 'folder', size: null, modifiedAt: '—' },
|
||||
{ id: 'u2', name: 'Public', type: 'folder', size: null, modifiedAt: '2026-02-14' },
|
||||
{ id: 'u3', name: 'Admin', type: 'folder', size: null, modifiedAt: '2026-02-15' },
|
||||
],
|
||||
'C:\\Documents\\Subcarpeta': [
|
||||
{ id: 's1', name: '..', type: 'folder', size: null, modifiedAt: '—' },
|
||||
{ id: 's2', name: 'datos.xlsx', type: 'file', size: '18 KB', modifiedAt: '2026-02-15' },
|
||||
],
|
||||
}
|
||||
|
||||
function getFilesForPath(path: string): FileItem[] {
|
||||
const normalized = path.replace(/\/$/, '').replace(/\//g, '\\')
|
||||
return MOCK_FILES_BY_PATH[normalized] ?? [{
|
||||
id: 'back',
|
||||
name: '..',
|
||||
type: 'folder',
|
||||
size: null,
|
||||
modifiedAt: '—',
|
||||
}]
|
||||
}
|
||||
|
||||
export default function FileExplorerPage() {
|
||||
const { selectedClientId } = useSelectedClient()
|
||||
const clienteId = selectedClientId ?? undefined
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const deviceIdFromUrl = searchParams.get('deviceId')
|
||||
|
||||
const listQuery = trpc.equipos.list.useQuery(
|
||||
{ clienteId, limit: 100 },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
const devices = useMemo(
|
||||
() => (listQuery.data?.dispositivos ?? []).map((d) => ({ id: d.id, nombre: d.nombre })),
|
||||
[listQuery.data]
|
||||
)
|
||||
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
|
||||
const [currentPath, setCurrentPath] = useState('C:\\')
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceIdFromUrl || !listQuery.data?.dispositivos?.length) return
|
||||
const exists = listQuery.data.dispositivos.some((d) => d.id === deviceIdFromUrl)
|
||||
if (exists) {
|
||||
setSelectedDeviceId(deviceIdFromUrl)
|
||||
setCurrentPath('C:\\')
|
||||
}
|
||||
}, [deviceIdFromUrl, listQuery.data?.dispositivos])
|
||||
|
||||
const selectedDevice = selectedDeviceId
|
||||
? devices.find((d) => d.id === selectedDeviceId)
|
||||
: null
|
||||
const selectedDeviceName = selectedDevice?.nombre ?? null
|
||||
|
||||
const files = useMemo(() => getFilesForPath(currentPath), [currentPath])
|
||||
|
||||
const handleFolderClick = useCallback((name: string) => {
|
||||
setCurrentPath((prev) => {
|
||||
if (name === '..') {
|
||||
const parts = prev.replace(/\\$/, '').split('\\')
|
||||
parts.pop()
|
||||
return parts.length > 1 ? parts.join('\\') + '\\' : 'C:\\'
|
||||
}
|
||||
const sep = prev.endsWith('\\') ? '' : '\\'
|
||||
return `${prev}${sep}${name}`
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
// Mock: no-op; in real impl would refetch file list
|
||||
}, [])
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
// Mock: no-op; in real impl would open file picker / upload
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">
|
||||
Explorador de Archivos
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
Navega y transfiere archivos remotamente
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<select
|
||||
value={selectedDeviceId}
|
||||
onChange={(e) => {
|
||||
setSelectedDeviceId(e.target.value)
|
||||
setCurrentPath('C:\\')
|
||||
}}
|
||||
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||
>
|
||||
<option value="">-- Seleccionar dispositivo --</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<FileExplorerContainer
|
||||
selectedDeviceName={selectedDeviceName}
|
||||
currentPath={currentPath}
|
||||
files={files}
|
||||
onFolderClick={handleFolderClick}
|
||||
onRefresh={handleRefresh}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
244
src/app/(dashboard)/devices/page.tsx
Normal file
244
src/app/(dashboard)/devices/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
src/app/(dashboard)/headwind/page.tsx
Normal file
73
src/app/(dashboard)/headwind/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import HeadwindMetricCard from '@/components/headwind/HeadwindMetricCard'
|
||||
import MobileDeviceList from '@/components/headwind/MobileDeviceList'
|
||||
import CorporateAppsList from '@/components/headwind/CorporateAppsList'
|
||||
import {
|
||||
getMdmDashboardData,
|
||||
type Device,
|
||||
type AppDeployment,
|
||||
type DashboardStats,
|
||||
} from '@/mocks/mdmDashboardData'
|
||||
|
||||
export default function HeadwindPage() {
|
||||
useSelectedClient()
|
||||
|
||||
const initial = useMemo(() => getMdmDashboardData(), [])
|
||||
const [stats] = useState<DashboardStats>(initial.stats)
|
||||
const [devices] = useState<Device[]>(initial.devices)
|
||||
const [appDeployments] = useState<AppDeployment[]>(initial.appDeployments)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
Headwind MDM
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
Gestión de dispositivos móviles Android
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
|
||||
aria-label="Resumen"
|
||||
>
|
||||
<HeadwindMetricCard
|
||||
label="Dispositivos Android"
|
||||
value={stats.totalAndroidDevices}
|
||||
subtitle="Total registrados"
|
||||
accent="green"
|
||||
/>
|
||||
<HeadwindMetricCard
|
||||
label="Apps desplegadas"
|
||||
value={stats.deployedApps}
|
||||
subtitle="En catálogo"
|
||||
accent="blue"
|
||||
/>
|
||||
<HeadwindMetricCard
|
||||
label="Políticas activas"
|
||||
value={stats.activePolicies}
|
||||
subtitle="Configuraciones aplicadas"
|
||||
accent="cyan"
|
||||
/>
|
||||
<HeadwindMetricCard
|
||||
label="Batería promedio"
|
||||
value={`${stats.averageBatteryPercent}%`}
|
||||
subtitle="Estado actual"
|
||||
accent="amber"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
|
||||
aria-label="Dispositivos y apps"
|
||||
>
|
||||
<MobileDeviceList devices={devices} />
|
||||
<CorporateAppsList apps={appDeployments} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}, [])
|
||||
if (meQuery.isError) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [meQuery.isError, router])
|
||||
|
||||
const handleLogout = async () => {
|
||||
// TODO: Implementar logout
|
||||
window.location.href = '/login'
|
||||
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>
|
||||
|
||||
105
src/app/(dashboard)/librenms/page.tsx
Normal file
105
src/app/(dashboard)/librenms/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import LibrenmsMetricCard from '@/components/librenms/LibrenmsMetricCard'
|
||||
import DeviceList from '@/components/librenms/DeviceList'
|
||||
import AlertList from '@/components/librenms/AlertList'
|
||||
import type { NetworkDevice } from '@/components/librenms/DeviceRow'
|
||||
import type { SnmpAlert } from '@/components/librenms/AlertItem'
|
||||
|
||||
const MOCK_DEVICES: NetworkDevice[] = [
|
||||
{ id: '1', name: 'Core Switch', model: 'Cisco 3850', status: 'online' },
|
||||
{ id: '2', name: 'Router Principal', model: 'MikroTik CCR', status: 'online' },
|
||||
{ id: '3', name: 'AP-Oficina-01', model: 'Ubiquiti UAP', status: 'online' },
|
||||
{ id: '4', name: 'Switch-Piso2', model: 'HP ProCurve', status: 'warning' },
|
||||
{ id: '5', name: 'Firewall', model: 'pfSense', status: 'online' },
|
||||
]
|
||||
|
||||
const MOCK_ALERTS: SnmpAlert[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'CPU Alto – Core Switch',
|
||||
detail: 'Hace 15 min – 92% utilización',
|
||||
severity: 'critical',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Puerto Down – Switch-Piso2',
|
||||
detail: 'Hace 1h – GigabitEthernet0/12',
|
||||
severity: 'warning',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Alto tráfico – WAN',
|
||||
detail: 'Hace 2h – 95% capacidad',
|
||||
severity: 'critical',
|
||||
},
|
||||
]
|
||||
|
||||
export default function LibrenmsPage() {
|
||||
useSelectedClient()
|
||||
|
||||
const [devices] = useState<NetworkDevice[]>(MOCK_DEVICES)
|
||||
const [alerts] = useState<SnmpAlert[]>(MOCK_ALERTS)
|
||||
const [metrics] = useState({
|
||||
totalDevices: 28,
|
||||
uptime: 99.7,
|
||||
activeAlerts: 3,
|
||||
trafficToday: 847,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
LibreNMS - Monitoreo de Red
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
SNMP, NetFlow y alertas de infraestructura
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* KPI metric cards */}
|
||||
<section
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
|
||||
aria-label="Métricas principales"
|
||||
>
|
||||
<LibrenmsMetricCard
|
||||
label="Dispositivos Red"
|
||||
value={metrics.totalDevices}
|
||||
subtitle="Switches, routers, APs"
|
||||
accent="green"
|
||||
/>
|
||||
<LibrenmsMetricCard
|
||||
label="Uptime Promedio"
|
||||
value={`${metrics.uptime}%`}
|
||||
subtitle="Últimos 30 días"
|
||||
accent="cyan"
|
||||
/>
|
||||
<LibrenmsMetricCard
|
||||
label="Alertas Activas"
|
||||
value={metrics.activeAlerts}
|
||||
subtitle="2 críticas, 1 warning"
|
||||
accent="yellow"
|
||||
/>
|
||||
<LibrenmsMetricCard
|
||||
label="Tráfico Total"
|
||||
value={`${metrics.trafficToday} GB`}
|
||||
subtitle="NetFlow hoy"
|
||||
accent="blue"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Main content grid */}
|
||||
<section
|
||||
className="grid grid-cols-1 gap-6 lg:grid-cols-2"
|
||||
aria-label="Dispositivos y alertas"
|
||||
>
|
||||
<DeviceList devices={devices} />
|
||||
<AlertList alerts={alerts} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,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 statsQuery = trpc.clientes.dashboardStats.useQuery(
|
||||
{ clienteId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
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 handleDeviceAction = (deviceId: string, action: string) => {
|
||||
console.log(`Action ${action} on device ${deviceId}`)
|
||||
// TODO: Implementar acciones
|
||||
const deviceStatusBreakdown = {
|
||||
online: stats.dispositivosOnline,
|
||||
offline: stats.dispositivosOffline,
|
||||
advertencia: stats.dispositivosAlerta,
|
||||
}
|
||||
|
||||
const handleAcknowledgeAlert = (alertId: string) => {
|
||||
setAlerts((prev) =>
|
||||
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RECONOCIDA' as const } : a))
|
||||
)
|
||||
// TODO: Llamar API
|
||||
}
|
||||
const allDevices = [
|
||||
...(equiposQuery.data?.dispositivos ?? []),
|
||||
...(redQuery.data?.dispositivos ?? []),
|
||||
...(celularesQuery.data?.dispositivos ?? []),
|
||||
].slice(0, DEVICES_LIMIT)
|
||||
|
||||
const handleResolveAlert = (alertId: string) => {
|
||||
setAlerts((prev) =>
|
||||
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RESUELTA' as const } : a))
|
||||
)
|
||||
// TODO: Llamar API
|
||||
}
|
||||
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>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar Dispositivo
|
||||
</Link>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<DeviceGrid
|
||||
devices={devices}
|
||||
viewMode={viewMode}
|
||||
onAction={handleDeviceAction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
<div>
|
||||
<AlertsFeed
|
||||
alerts={alerts}
|
||||
onAcknowledge={handleAcknowledgeAlert}
|
||||
onResolve={handleResolveAlert}
|
||||
/>
|
||||
</div>
|
||||
<section className="lg:col-span-2">
|
||||
<DeviceStatusChart data={deviceStatusBreakdown} />
|
||||
</section>
|
||||
<section className="min-h-0">
|
||||
<RecentActivityList items={recentActivity} isLoading={alertsQuery.isLoading} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-medium text-gray-200 mb-4">Salud del Sistema</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.cpu} />
|
||||
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.ram} />
|
||||
<SystemHealthCard metric={MOCK_SYSTEM_HEALTH.network} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-medium text-gray-200 mb-4">Conexión Rápida</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{quickConnections.map((item) => (
|
||||
<QuickConnectionCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={(id) => {
|
||||
// TODO: router.push(`/devices?id=${id}`) or open device detail modal
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function mapEstadoToQuickStatus(estado: string): QuickConnectionStatus {
|
||||
const u = estado?.toUpperCase()
|
||||
if (u === 'ONLINE') return 'online'
|
||||
if (u === 'ALERTA') return 'advertencia'
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
221
src/app/(dashboard)/rendimiento/page.tsx
Normal file
221
src/app/(dashboard)/rendimiento/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import MetricCard from '@/components/performance/MetricCard'
|
||||
import ProcessTable from '@/components/performance/ProcessTable'
|
||||
import type { ProcessItem } from '@/components/performance/ProcessRow'
|
||||
|
||||
const CHART_POINTS = 20
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
|
||||
function randomIn(min: number, max: number) {
|
||||
return Math.round(min + Math.random() * (max - min))
|
||||
}
|
||||
|
||||
function generateMockProcesses(): ProcessItem[] {
|
||||
const names = [
|
||||
'chrome.exe',
|
||||
'Code.exe',
|
||||
'node.exe',
|
||||
'System',
|
||||
'svchost.exe',
|
||||
'explorer.exe',
|
||||
'MsMpEng.exe',
|
||||
'SearchHost.exe',
|
||||
'RuntimeBroker.exe',
|
||||
'dllhost.exe',
|
||||
]
|
||||
return names.slice(0, 10).map((name, i) => ({
|
||||
id: `p-${i}`,
|
||||
name,
|
||||
pid: 1000 + i * 100 + randomIn(0, 99),
|
||||
cpu: randomIn(0, 45),
|
||||
memory: `${randomIn(50, 800)} MB`,
|
||||
state: i % 3 === 0 ? 'En ejecución' : 'Activo',
|
||||
}))
|
||||
}
|
||||
|
||||
export default function RendimientoPage() {
|
||||
const { selectedClientId } = useSelectedClient()
|
||||
const clienteId = selectedClientId ?? undefined
|
||||
|
||||
const listQuery = trpc.equipos.list.useQuery(
|
||||
{ clienteId, limit: 100 },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
const devices = useMemo(
|
||||
() => (listQuery.data?.dispositivos ?? []).map((d) => ({ id: d.id, nombre: d.nombre })),
|
||||
[listQuery.data]
|
||||
)
|
||||
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
|
||||
const [metrics, setMetrics] = useState({
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
disk: 0,
|
||||
network: 0,
|
||||
})
|
||||
const [chartHistory, setChartHistory] = useState<{
|
||||
cpu: number[]
|
||||
memory: number[]
|
||||
disk: number[]
|
||||
network: number[]
|
||||
}>({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
disk: [],
|
||||
network: [],
|
||||
})
|
||||
const [processes, setProcesses] = useState<ProcessItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const hasDevice = !!selectedDeviceId
|
||||
|
||||
const tick = useCallback(() => {
|
||||
const cpu = randomIn(15, 85)
|
||||
const memory = randomIn(40, 90)
|
||||
const disk = randomIn(25, 65)
|
||||
const network = randomIn(5, 120)
|
||||
setMetrics({ cpu, memory, disk, network })
|
||||
setChartHistory((prev) => ({
|
||||
cpu: [...prev.cpu, cpu].slice(-CHART_POINTS),
|
||||
memory: [...prev.memory, memory].slice(-CHART_POINTS),
|
||||
disk: [...prev.disk, disk].slice(-CHART_POINTS),
|
||||
network: [...prev.network, network].slice(-CHART_POINTS),
|
||||
}))
|
||||
setProcesses(generateMockProcesses())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasDevice) {
|
||||
setMetrics({ cpu: 0, memory: 0, disk: 0, network: 0 })
|
||||
setChartHistory({ cpu: [], memory: [], disk: [], network: [] })
|
||||
setProcesses([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
tick()
|
||||
const t = setTimeout(() => setLoading(false), 400)
|
||||
const interval = setInterval(tick, POLL_INTERVAL_MS)
|
||||
return () => {
|
||||
clearTimeout(t)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [hasDevice, tick])
|
||||
|
||||
const cpuFooter = hasDevice
|
||||
? [
|
||||
{ label: 'Procesos:', value: '142' },
|
||||
{ label: 'Hilos:', value: '2,840' },
|
||||
{ label: 'Velocidad:', value: '2.90 GHz' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Procesos:', value: '—' },
|
||||
{ label: 'Hilos:', value: '—' },
|
||||
{ label: 'Velocidad:', value: '—' },
|
||||
]
|
||||
|
||||
const ramFooter = hasDevice
|
||||
? [
|
||||
{ label: 'En uso:', value: `${Math.round((metrics.memory / 100) * 16)} GB` },
|
||||
{ label: 'Disponible:', value: `${Math.round(((100 - metrics.memory) / 100) * 16)} GB` },
|
||||
{ label: 'Total:', value: '16 GB' },
|
||||
]
|
||||
: [
|
||||
{ label: 'En uso:', value: '—' },
|
||||
{ label: 'Disponible:', value: '—' },
|
||||
{ label: 'Total:', value: '—' },
|
||||
]
|
||||
|
||||
const diskFooter = hasDevice
|
||||
? [
|
||||
{ label: 'Lectura:', value: '12.5 MB/s' },
|
||||
{ label: 'Escritura:', value: '3.2 MB/s' },
|
||||
{ label: 'Activo:', value: 'Sí' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Lectura:', value: '—' },
|
||||
{ label: 'Escritura:', value: '—' },
|
||||
{ label: 'Activo:', value: '—' },
|
||||
]
|
||||
|
||||
const networkFooter = hasDevice
|
||||
? [
|
||||
{ label: 'Enviado:', value: '1.2 GB' },
|
||||
{ label: 'Recibido:', value: '4.8 GB' },
|
||||
{ label: 'Adaptador:', value: 'Ethernet' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Enviado:', value: '—' },
|
||||
{ label: 'Recibido:', value: '—' },
|
||||
{ label: 'Adaptador:', value: '—' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">
|
||||
Rendimiento en Tiempo Real
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
Monitorea recursos del sistema
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<select
|
||||
value={selectedDeviceId}
|
||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
||||
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">-- Seleccionar dispositivo --</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<MetricCard
|
||||
title="CPU"
|
||||
value={hasDevice ? `${metrics.cpu}` : '—'}
|
||||
valueSuffix="%"
|
||||
footerStats={cpuFooter}
|
||||
chartData={chartHistory.cpu}
|
||||
highUsage={metrics.cpu > 80}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Memoria RAM"
|
||||
value={hasDevice ? `${metrics.memory}` : '—'}
|
||||
valueSuffix="%"
|
||||
footerStats={ramFooter}
|
||||
chartData={chartHistory.memory}
|
||||
highUsage={metrics.memory > 80}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Disco"
|
||||
value={hasDevice ? `${metrics.disk}` : '—'}
|
||||
valueSuffix="%"
|
||||
footerStats={diskFooter}
|
||||
chartData={chartHistory.disk}
|
||||
highUsage={metrics.disk > 80}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Red"
|
||||
value={hasDevice ? `${metrics.network}` : '—'}
|
||||
valueSuffix="Mbps"
|
||||
footerStats={networkFooter}
|
||||
chartData={chartHistory.network}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProcessTable processes={processes} noDevice={!hasDevice} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
src/app/(dashboard)/reportes/page.tsx
Normal file
185
src/app/(dashboard)/reportes/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
// TODO: replace mock reportService with trpc.reportes.inventario / reportes.alertas / reportes.exportarCSV and real PDF generation
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import DateRangeFilter, { type DateRangeValue } from '@/components/reportes/DateRangeFilter'
|
||||
import ReportCard from '@/components/reportes/ReportCard'
|
||||
import {
|
||||
fetchInventoryReport,
|
||||
fetchResourceUsageReport,
|
||||
fetchAlertsReport,
|
||||
} from '@/mocks/reportService'
|
||||
|
||||
function defaultDateRange(): DateRangeValue {
|
||||
const today = new Date()
|
||||
const monthAgo = new Date(today)
|
||||
monthAgo.setMonth(monthAgo.getMonth() - 1)
|
||||
return {
|
||||
desde: monthAgo.toISOString().split('T')[0],
|
||||
hasta: today.toISOString().split('T')[0],
|
||||
}
|
||||
}
|
||||
|
||||
export default function ReportesPage() {
|
||||
useSelectedClient()
|
||||
|
||||
const [filters, setFilters] = useState<DateRangeValue>(defaultDateRange)
|
||||
const [loading, setLoading] = useState<string | null>(null)
|
||||
|
||||
const getDates = useCallback((): { start: Date; end: Date } | null => {
|
||||
if (!filters.desde || !filters.hasta || filters.hasta < filters.desde)
|
||||
return null
|
||||
return {
|
||||
start: new Date(filters.desde),
|
||||
end: new Date(filters.hasta),
|
||||
}
|
||||
}, [filters])
|
||||
|
||||
const mockExportPdf = useCallback(
|
||||
(reportName: string) => {
|
||||
const dates = getDates()
|
||||
console.log(`[Mock] Export PDF: ${reportName}`, dates || filters)
|
||||
},
|
||||
[getDates, filters]
|
||||
)
|
||||
|
||||
const mockExportExcel = useCallback(
|
||||
(reportName: string) => {
|
||||
const dates = getDates()
|
||||
const csv = `Reporte,${reportName}\nDesde,${filters.desde || '-'}\nHasta,${filters.hasta || '-'}\n`
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `reporte-${reportName.toLowerCase().replace(/\s+/g, '-')}-${filters.desde || 'export'}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
[getDates, filters]
|
||||
)
|
||||
|
||||
const handleInventoryPdf = () => {
|
||||
setLoading('inventario-pdf')
|
||||
const dates = getDates()
|
||||
if (dates) {
|
||||
fetchInventoryReport(dates.start, dates.end).then(() => {
|
||||
mockExportPdf('Inventario de Equipos')
|
||||
setLoading(null)
|
||||
})
|
||||
} else {
|
||||
mockExportPdf('Inventario de Equipos')
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInventoryExcel = () => {
|
||||
setLoading('inventario-excel')
|
||||
const dates = getDates()
|
||||
if (dates) {
|
||||
fetchInventoryReport(dates.start, dates.end).then(() => {
|
||||
mockExportExcel('Inventario de Equipos')
|
||||
setLoading(null)
|
||||
})
|
||||
} else {
|
||||
mockExportExcel('Inventario de Equipos')
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResourcePdf = () => {
|
||||
setLoading('recursos-pdf')
|
||||
const dates = getDates()
|
||||
if (dates) {
|
||||
fetchResourceUsageReport(dates.start, dates.end).then(() => {
|
||||
mockExportPdf('Uso de Recursos')
|
||||
setLoading(null)
|
||||
})
|
||||
} else {
|
||||
mockExportPdf('Uso de Recursos')
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResourceExcel = () => {
|
||||
setLoading('recursos-excel')
|
||||
const dates = getDates()
|
||||
if (dates) {
|
||||
fetchResourceUsageReport(dates.start, dates.end).then(() => {
|
||||
mockExportExcel('Uso de Recursos')
|
||||
setLoading(null)
|
||||
})
|
||||
} else {
|
||||
mockExportExcel('Uso de Recursos')
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAlertsPdf = () => {
|
||||
setLoading('alertas-pdf')
|
||||
const dates = getDates()
|
||||
if (dates) {
|
||||
fetchAlertsReport(dates.start, dates.end).then(() => {
|
||||
mockExportPdf('Historial de Alertas')
|
||||
setLoading(null)
|
||||
})
|
||||
} else {
|
||||
mockExportPdf('Historial de Alertas')
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAlertsExcel = () => {
|
||||
setLoading('alertas-excel')
|
||||
const dates = getDates()
|
||||
if (dates) {
|
||||
fetchAlertsReport(dates.start, dates.end).then(() => {
|
||||
mockExportExcel('Historial de Alertas')
|
||||
setLoading(null)
|
||||
})
|
||||
} else {
|
||||
mockExportExcel('Historial de Alertas')
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold text-white">Reportes</h1>
|
||||
<p className="mt-1 text-gray-400">Generación de informes del sistema</p>
|
||||
</header>
|
||||
|
||||
<section className="py-4">
|
||||
<DateRangeFilter value={filters} onChange={setFilters} />
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
|
||||
aria-label="Reportes disponibles"
|
||||
>
|
||||
<ReportCard
|
||||
title="Inventario de Equipos"
|
||||
description="Listado completo de todos los dispositivos registrados con especificaciones técnicas y estado actual."
|
||||
onExportPdf={handleInventoryPdf}
|
||||
onExportExcel={handleInventoryExcel}
|
||||
loading={loading?.startsWith('inventario') ?? false}
|
||||
/>
|
||||
<ReportCard
|
||||
title="Uso de Recursos"
|
||||
description="Estadísticas de consumo de CPU, memoria y red de todos los dispositivos en el periodo seleccionado."
|
||||
onExportPdf={handleResourcePdf}
|
||||
onExportExcel={handleResourceExcel}
|
||||
loading={loading?.startsWith('recursos') ?? false}
|
||||
/>
|
||||
<ReportCard
|
||||
title="Historial de Alertas"
|
||||
description="Registro completo de todas las alertas generadas, incluyendo resolución y tiempo de respuesta."
|
||||
onExportPdf={handleAlertsPdf}
|
||||
onExportExcel={handleAlertsExcel}
|
||||
loading={loading?.startsWith('alertas') ?? false}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
src/app/(dashboard)/sesiones/page.tsx
Normal file
141
src/app/(dashboard)/sesiones/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { formatDurationSeconds } from '@/lib/utils'
|
||||
import SessionCard, { type SessionTypeLabel } from '@/components/sessions/SessionCard'
|
||||
|
||||
const TIPO_TO_LABEL: Record<string, SessionTypeLabel> = {
|
||||
desktop: 'Escritorio Remoto',
|
||||
terminal: 'Terminal',
|
||||
files: 'Archivos',
|
||||
}
|
||||
|
||||
function getSessionTypeLabel(tipo: string): SessionTypeLabel {
|
||||
return TIPO_TO_LABEL[tipo] ?? 'Terminal'
|
||||
}
|
||||
|
||||
function computeDurationSeconds(startedAt: Date | string): number {
|
||||
const start = new Date(startedAt).getTime()
|
||||
return Math.floor((Date.now() - start) / 1000)
|
||||
}
|
||||
|
||||
export default function SesionesPage() {
|
||||
const { selectedClientId } = useSelectedClient()
|
||||
const clienteId = selectedClientId ?? undefined
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const listQuery = trpc.sesiones.list.useQuery(
|
||||
{ clienteId, limit: 100 },
|
||||
{ refetchOnWindowFocus: true, staleTime: 10 * 1000 }
|
||||
)
|
||||
|
||||
const [liveDurations, setLiveDurations] = useState<Record<string, number>>({})
|
||||
|
||||
const sessions = useMemo(() => {
|
||||
const list = listQuery.data?.sessions ?? []
|
||||
return list.map((s) => {
|
||||
const startedAt = s.iniciadaEn instanceof Date ? s.iniciadaEn : new Date(s.iniciadaEn)
|
||||
const seconds = computeDurationSeconds(startedAt)
|
||||
return {
|
||||
id: s.id,
|
||||
deviceName: s.dispositivo.nombre,
|
||||
userEmail: s.usuario.email,
|
||||
sessionType: getSessionTypeLabel(s.tipo),
|
||||
startedAt,
|
||||
initialSeconds: seconds,
|
||||
}
|
||||
})
|
||||
}, [listQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessions.length === 0) return
|
||||
const interval = setInterval(() => {
|
||||
const next: Record<string, number> = {}
|
||||
sessions.forEach((s) => {
|
||||
next[s.id] = computeDurationSeconds(s.startedAt)
|
||||
})
|
||||
setLiveDurations((prev) => ({ ...prev, ...next }))
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [sessions])
|
||||
|
||||
const endSessionMutation = trpc.equipos.finalizarSesion.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.sesiones.list.invalidate()
|
||||
utils.sesiones.count.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const handleEndSession = (sessionId: string) => {
|
||||
if (typeof window !== 'undefined' && !window.confirm('¿Terminar esta sesión?')) return
|
||||
endSessionMutation.mutate({ sesionId: sessionId })
|
||||
}
|
||||
|
||||
if (listQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
|
||||
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
|
||||
</header>
|
||||
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
|
||||
Cargando sesiones...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (listQuery.isError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
|
||||
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
|
||||
</header>
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-12 text-center text-red-400">
|
||||
Error al cargar sesiones. Intente de nuevo.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">Sesiones Activas</h1>
|
||||
<p className="mt-1 text-gray-400">Conexiones remotas en curso</p>
|
||||
</header>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
|
||||
No hay sesiones activas.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-5">
|
||||
{sessions.map((session) => {
|
||||
const durationSeconds = liveDurations[session.id] ?? session.initialSeconds
|
||||
const duration = formatDurationSeconds(durationSeconds)
|
||||
const isEnding = endSessionMutation.isPending && endSessionMutation.variables?.sesionId === session.id
|
||||
|
||||
return (
|
||||
<li key={session.id}>
|
||||
<SessionCard
|
||||
id={session.id}
|
||||
deviceName={session.deviceName}
|
||||
userEmail={session.userEmail}
|
||||
sessionType={session.sessionType}
|
||||
duration={duration}
|
||||
onEnd={handleEndSession}
|
||||
isEnding={isEnding}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
207
src/app/(dashboard)/software/page.tsx
Normal file
207
src/app/(dashboard)/software/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import SummaryMetricCard from '@/components/software/SummaryMetricCard'
|
||||
import SoftwareTable from '@/components/software/SoftwareTable'
|
||||
import type { SoftwareItem } from '@/components/software/SoftwareRow'
|
||||
import { Download } from 'lucide-react'
|
||||
|
||||
const MOCK_SOFTWARE: SoftwareItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Google Chrome',
|
||||
version: '120.0.6099.130',
|
||||
vendor: 'Google LLC',
|
||||
installations: 10,
|
||||
lastUpdate: '15/01/2024',
|
||||
licensed: false,
|
||||
needsUpdate: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Microsoft Office 365',
|
||||
version: '16.0.17029.20028',
|
||||
vendor: 'Microsoft Corporation',
|
||||
installations: 8,
|
||||
lastUpdate: '20/01/2024',
|
||||
licensed: true,
|
||||
needsUpdate: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Adobe Acrobat Reader DC',
|
||||
version: '23.006.20360',
|
||||
vendor: 'Adobe Inc.',
|
||||
installations: 12,
|
||||
lastUpdate: '10/01/2024',
|
||||
licensed: true,
|
||||
needsUpdate: true,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Visual Studio Code',
|
||||
version: '1.85.1',
|
||||
vendor: 'Microsoft Corporation',
|
||||
installations: 5,
|
||||
lastUpdate: '18/01/2024',
|
||||
licensed: false,
|
||||
needsUpdate: false,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Slack',
|
||||
version: '4.33.90',
|
||||
vendor: 'Slack Technologies',
|
||||
installations: 7,
|
||||
lastUpdate: '12/01/2024',
|
||||
licensed: true,
|
||||
needsUpdate: false,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Zoom',
|
||||
version: '5.16.10',
|
||||
vendor: 'Zoom Video Communications',
|
||||
installations: 6,
|
||||
lastUpdate: '08/01/2024',
|
||||
licensed: false,
|
||||
needsUpdate: true,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: '7-Zip',
|
||||
version: '23.01',
|
||||
vendor: 'Igor Pavlov',
|
||||
installations: 15,
|
||||
lastUpdate: '22/12/2023',
|
||||
licensed: false,
|
||||
needsUpdate: false,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'Node.js',
|
||||
version: '20.10.0',
|
||||
vendor: 'OpenJS Foundation',
|
||||
installations: 4,
|
||||
lastUpdate: '05/01/2024',
|
||||
licensed: false,
|
||||
needsUpdate: true,
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: 'Git',
|
||||
version: '2.43.0',
|
||||
vendor: 'Software Freedom Conservancy',
|
||||
installations: 5,
|
||||
lastUpdate: '14/01/2024',
|
||||
licensed: false,
|
||||
needsUpdate: false,
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: 'Windows Security',
|
||||
version: '10.0.22621.1',
|
||||
vendor: 'Microsoft Corporation',
|
||||
installations: 12,
|
||||
lastUpdate: '19/01/2024',
|
||||
licensed: true,
|
||||
needsUpdate: false,
|
||||
},
|
||||
]
|
||||
|
||||
function exportToCsv(items: SoftwareItem[]): string {
|
||||
const headers = ['Nombre', 'Versión', 'Editor', 'Instalaciones', 'Última actualización']
|
||||
const rows = items.map((i) =>
|
||||
[i.name, i.version, i.vendor, i.installations, i.lastUpdate].join(',')
|
||||
)
|
||||
return [headers.join(','), ...rows].join('\n')
|
||||
}
|
||||
|
||||
export default function InventarioPage() {
|
||||
useSelectedClient()
|
||||
const [softwareList] = useState<SoftwareItem[]>(MOCK_SOFTWARE)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filteredSoftware = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return softwareList
|
||||
return softwareList.filter((item) =>
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.vendor.toLowerCase().includes(q) ||
|
||||
item.version.toLowerCase().includes(q)
|
||||
)
|
||||
}, [softwareList, search])
|
||||
|
||||
const uniqueCount = filteredSoftware.length
|
||||
const licensedCount = useMemo(
|
||||
() => filteredSoftware.filter((i) => i.licensed).length,
|
||||
[filteredSoftware]
|
||||
)
|
||||
const needsUpdateCount = useMemo(
|
||||
() => filteredSoftware.filter((i) => i.needsUpdate).length,
|
||||
[filteredSoftware]
|
||||
)
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const csv = exportToCsv(filteredSoftware)
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `inventario-software-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [filteredSoftware])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">
|
||||
Inventario de Software
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
Software instalado en los dispositivos
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar software..."
|
||||
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2 text-sm text-gray-200 placeholder-gray-500 transition-colors focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-cyan-500"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Exportar
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<SummaryMetricCard
|
||||
value={uniqueCount}
|
||||
label="Programas Únicos"
|
||||
/>
|
||||
<SummaryMetricCard
|
||||
value={licensedCount}
|
||||
label="Con Licencia"
|
||||
/>
|
||||
<SummaryMetricCard
|
||||
value={needsUpdateCount}
|
||||
label="Requieren Actualización"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||
<SoftwareTable items={filteredSoftware} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
src/app/(dashboard)/terminal/page.tsx
Normal file
124
src/app/(dashboard)/terminal/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import TerminalWindow from '@/components/terminal/TerminalWindow'
|
||||
import QuickCommands from '@/components/terminal/QuickCommands'
|
||||
|
||||
const MOCK_RESPONSES: Record<string, string> = {
|
||||
systeminfo: 'Nombre del host: PC-RECEPCION-01\nOS: Microsoft Windows 11 Pro\n...',
|
||||
ipconfig: 'Adaptador Ethernet: 192.168.1.10\nMáscara de subred: 255.255.255.0\n...',
|
||||
tasklist: 'Nombre de imagen PID Nombre de sesión\nchrome.exe 1234 Console\n...',
|
||||
netstat: 'Conexiones activas\n Proto Dirección local Dirección remota\n TCP 0.0.0.0:443 LISTENING\n...',
|
||||
'CPU Info': 'Procesador: Intel Core i7-10700\nUso: 23%\n...',
|
||||
'RAM Info': 'Memoria total: 16 GB\nEn uso: 8.2 GB\nDisponible: 7.8 GB\n...',
|
||||
'dir C:\\': ' Volume in drive C is OS\n Directory of C:\\\n archivos...\n...',
|
||||
hostname: 'PC-RECEPCION-01',
|
||||
}
|
||||
|
||||
function getMockResponse(cmd: string): string {
|
||||
return MOCK_RESPONSES[cmd] ?? `Comando ejecutado: ${cmd}\n(Salida simulada)`
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { selectedClientId } = useSelectedClient()
|
||||
const clienteId = selectedClientId ?? undefined
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const deviceIdFromUrl = searchParams.get('deviceId')
|
||||
|
||||
const listQuery = trpc.equipos.list.useQuery(
|
||||
{ clienteId, limit: 100 },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
const devices = (listQuery.data?.dispositivos ?? []).map((d) => ({
|
||||
id: d.id,
|
||||
nombre: d.nombre,
|
||||
}))
|
||||
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceIdFromUrl || !listQuery.data?.dispositivos?.length) return
|
||||
const exists = listQuery.data.dispositivos.some((d) => d.id === deviceIdFromUrl)
|
||||
if (exists) setSelectedDeviceId(deviceIdFromUrl)
|
||||
}, [deviceIdFromUrl, listQuery.data?.dispositivos])
|
||||
const [outputLines, setOutputLines] = useState<string[]>([])
|
||||
const [command, setCommand] = useState('')
|
||||
|
||||
const selectedDevice = selectedDeviceId
|
||||
? devices.find((d) => d.id === selectedDeviceId)
|
||||
: null
|
||||
const connectedDeviceName = selectedDevice?.nombre ?? null
|
||||
const isConnected = !!selectedDeviceId
|
||||
|
||||
const handleSendCommand = useCallback(() => {
|
||||
const trimmed = command.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
const response = getMockResponse(trimmed)
|
||||
const newLines = [`$ ${trimmed}`, ...response.split('\n').map((line) => `> ${line}`)]
|
||||
setOutputLines((prev) => [...prev, ...newLines])
|
||||
setCommand('')
|
||||
}, [command])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setOutputLines([])
|
||||
}, [])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const text = outputLines.join('\n')
|
||||
if (text) {
|
||||
void navigator.clipboard.writeText(text)
|
||||
}
|
||||
}, [outputLines])
|
||||
|
||||
const handleQuickCommand = useCallback((cmd: string) => {
|
||||
setCommand(cmd)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">
|
||||
Terminal Remoto
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-400">
|
||||
Ejecuta comandos en dispositivos conectados
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<select
|
||||
value={selectedDeviceId}
|
||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
||||
className="w-64 rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||
>
|
||||
<option value="">-- Seleccionar dispositivo --</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<TerminalWindow
|
||||
connectedDeviceName={connectedDeviceName}
|
||||
outputLines={outputLines}
|
||||
command={command}
|
||||
onCommandChange={setCommand}
|
||||
onSendCommand={handleSendCommand}
|
||||
onClear={handleClear}
|
||||
onCopy={handleCopy}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
|
||||
<QuickCommands onSelectCommand={handleQuickCommand} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
src/app/api/trpc/[trpc]/route.ts
Normal file
13
src/app/api/trpc/[trpc]/route.ts
Normal 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 }
|
||||
@@ -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
105
src/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
src/components/alerts/AlertCard.tsx
Normal file
100
src/components/alerts/AlertCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
src/components/alerts/AlertsSection.tsx
Normal file
70
src/components/alerts/AlertsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
src/components/alerts/AlertsTabs.tsx
Normal file
39
src/components/alerts/AlertsTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,88 +88,75 @@ 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">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-1 rounded hover:bg-dark-100 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
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>
|
||||
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||||
<div className="dropdown right-0 z-50">
|
||||
{['PC', 'LAPTOP', 'SERVIDOR'].includes(device.tipo) && device.estado === 'ONLINE' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'desktop')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Escritorio remoto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'terminal')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'files')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
Archivos
|
||||
</button>
|
||||
<div className="h-px bg-dark-100 my-1" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'restart')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2 text-warning"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||||
<div className="dropdown right-0 z-50">
|
||||
{['PC', 'LAPTOP', 'SERVIDOR'].includes(device.tipo) && device.estado === 'ONLINE' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'desktop')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Escritorio remoto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'terminal')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'files')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
Archivos
|
||||
</button>
|
||||
<div className="h-px bg-dark-100 my-1" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
onAction?.(device.id, 'restart')
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="dropdown-item flex items-center gap-2 text-warning"
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
Reiniciar
|
||||
</button>
|
||||
</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
|
||||
|
||||
88
src/components/dashboard/DeviceStatusChart.tsx
Normal file
88
src/components/dashboard/DeviceStatusChart.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
|
||||
import type { DeviceStatusBreakdown } from '@/mocks/dashboardData'
|
||||
|
||||
interface DeviceStatusChartProps {
|
||||
data: DeviceStatusBreakdown
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
online: '#22c55e',
|
||||
offline: '#64748b',
|
||||
advertencia: '#eab308',
|
||||
}
|
||||
|
||||
export default function DeviceStatusChart({ data }: DeviceStatusChartProps) {
|
||||
const total = data.online + data.offline + data.advertencia
|
||||
const segments = [
|
||||
{ name: 'En Línea', value: data.online, color: COLORS.online },
|
||||
{ name: 'Fuera de Línea', value: data.offline, color: COLORS.offline },
|
||||
{ name: 'Advertencia', value: data.advertencia, color: COLORS.advertencia },
|
||||
].filter((s) => s.value > 0)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<h3 className="font-medium text-gray-200 mb-4">Estado de Dispositivos</h3>
|
||||
<div className="flex h-48 items-center justify-center text-gray-500 text-sm">
|
||||
Sin datos
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<h3 className="font-medium text-gray-200 mb-4">Estado de Dispositivos</h3>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="w-full max-w-[200px] h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={segments}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={56}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{segments.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.color} stroke="transparent" />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`${value} (${total > 0 ? Math.round((value / total) * 100) : 0}%)`,
|
||||
'',
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2 w-full sm:w-auto">
|
||||
{segments.map((s) => (
|
||||
<li key={s.name} className="flex items-center justify-between gap-4 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="text-gray-400 tabular-nums">
|
||||
{s.value} ({total > 0 ? Math.round((s.value / total) * 100) : 0}%)
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/dashboard/QuickConnectionCard.tsx
Normal file
38
src/components/dashboard/QuickConnectionCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QuickConnectionItem, QuickConnectionStatus } from '@/mocks/dashboardData'
|
||||
|
||||
interface QuickConnectionCardProps {
|
||||
item: QuickConnectionItem
|
||||
onClick?: (id: string) => void
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
QuickConnectionStatus,
|
||||
{ dot: string; label: string; text: string }
|
||||
> = {
|
||||
online: { dot: 'bg-success', label: 'En línea', text: 'text-success' },
|
||||
advertencia: { dot: 'bg-warning', label: 'Advertencia', text: 'text-warning' },
|
||||
offline: { dot: 'bg-gray-500', label: 'Fuera de línea', text: 'text-gray-500' },
|
||||
}
|
||||
|
||||
export default function QuickConnectionCard({ item, onClick }: QuickConnectionCardProps) {
|
||||
const config = statusConfig[item.status]
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(item.id)}
|
||||
className={cn(
|
||||
'card p-4 text-left transition-all hover:scale-[1.02] hover:border-primary-500/50',
|
||||
'flex items-center justify-between gap-3'
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-gray-200 truncate">{item.name}</span>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm shrink-0', config.text)}>
|
||||
<span className={cn('h-2 w-2 rounded-full', config.dot)} />
|
||||
{config.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
92
src/components/dashboard/RecentActivityList.tsx
Normal file
92
src/components/dashboard/RecentActivityList.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
LogIn,
|
||||
AlertTriangle,
|
||||
Link2,
|
||||
Link2Off,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
import type { RecentActivityItem, RecentActivityType } from '@/mocks/dashboardData'
|
||||
import type { AlertSeverity } from '@/mocks/dashboardData'
|
||||
|
||||
function formatRelative(date: Date): string {
|
||||
const s = formatRelativeTime(date)
|
||||
return s === 'ahora' ? 'Hace un momento' : s.replace(/^hace/, 'Hace')
|
||||
}
|
||||
|
||||
interface RecentActivityListProps {
|
||||
items: RecentActivityItem[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const severityIconBg: Record<AlertSeverity, string> = {
|
||||
CRITICAL: 'bg-danger/20 text-danger',
|
||||
WARNING: 'bg-warning/20 text-warning',
|
||||
INFO: 'bg-info/20 text-info',
|
||||
}
|
||||
|
||||
const typeConfig: Record<
|
||||
RecentActivityType,
|
||||
{ icon: React.ReactNode; label: string }
|
||||
> = {
|
||||
login: { icon: <LogIn className="w-4 h-4" />, label: 'Login' },
|
||||
alert: { icon: <AlertTriangle className="w-4 h-4" />, label: 'Alerta' },
|
||||
connection: { icon: <Link2 className="w-4 h-4" />, label: 'Conexión' },
|
||||
disconnection: { icon: <Link2Off className="w-4 h-4" />, label: 'Desconexión' },
|
||||
}
|
||||
|
||||
export default function RecentActivityList({ items, isLoading }: RecentActivityListProps) {
|
||||
return (
|
||||
<div className="card overflow-hidden flex flex-col h-full min-h-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-200">Actividad Reciente</h3>
|
||||
<Link href="/alerts" className="text-sm text-primary-500 hover:underline">
|
||||
Ver todas
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 divide-y divide-dark-100 max-h-[320px]">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
Cargando...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
No hay alertas recientes
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const config = typeConfig[item.type]
|
||||
const iconBg =
|
||||
item.type === 'alert' && item.severity
|
||||
? severityIconBg[item.severity]
|
||||
: 'bg-dark-300 text-gray-400'
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 p-4 hover:bg-dark-300/30 transition-colors"
|
||||
>
|
||||
<div className={cn('p-2 rounded-lg shrink-0', iconBg)}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-gray-200">
|
||||
{item.description}
|
||||
{' · '}
|
||||
<span className="font-medium text-primary-400">
|
||||
{item.deviceName}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{formatRelative(item.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/dashboard/SummaryCard.tsx
Normal file
38
src/components/dashboard/SummaryCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string
|
||||
value: number
|
||||
secondary?: string
|
||||
icon: React.ReactNode
|
||||
iconBgClass?: string
|
||||
iconColorClass?: string
|
||||
}
|
||||
|
||||
export default function SummaryCard({
|
||||
title,
|
||||
value,
|
||||
secondary,
|
||||
icon,
|
||||
iconBgClass = 'bg-dark-300',
|
||||
iconColorClass = 'text-primary-400',
|
||||
}: SummaryCardProps) {
|
||||
return (
|
||||
<div className="card p-4 transition-all hover:scale-[1.02]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{title}</p>
|
||||
<p className="text-3xl font-bold mt-1">{value}</p>
|
||||
{secondary && (
|
||||
<p className="text-xs text-gray-500 mt-1">{secondary}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('p-3 rounded-lg', iconBgClass)}>
|
||||
<span className={iconColorClass}>{icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/components/dashboard/SystemHealthCard.tsx
Normal file
37
src/components/dashboard/SystemHealthCard.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SystemHealthMetric } from '@/mocks/dashboardData'
|
||||
|
||||
interface SystemHealthCardProps {
|
||||
metric: SystemHealthMetric
|
||||
}
|
||||
|
||||
const statusBarClass = {
|
||||
healthy: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
critical: 'bg-danger',
|
||||
}
|
||||
|
||||
export default function SystemHealthCard({ metric }: SystemHealthCardProps) {
|
||||
const percent = metric.unit === '%' ? metric.value : Math.min(100, (metric.value / 200) * 100)
|
||||
const barClass = statusBarClass[metric.status]
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">{metric.label}</span>
|
||||
<span className="text-lg font-semibold tabular-nums text-gray-200">
|
||||
{metric.value}
|
||||
{metric.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-dark-300 overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', barClass)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
373
src/components/devices/AddDeviceModal.tsx
Normal file
373
src/components/devices/AddDeviceModal.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TIPO_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'PC', label: 'PC' },
|
||||
{ value: 'LAPTOP', label: 'Laptop' },
|
||||
{ value: 'SERVIDOR', label: 'Servidor' },
|
||||
{ value: 'CELULAR', label: 'Celular' },
|
||||
{ value: 'TABLET', label: 'Tablet' },
|
||||
{ value: 'ROUTER', label: 'Router' },
|
||||
{ value: 'SWITCH', label: 'Switch' },
|
||||
{ value: 'FIREWALL', label: 'Firewall' },
|
||||
{ value: 'AP', label: 'Access Point' },
|
||||
{ value: 'IMPRESORA', label: 'Impresora' },
|
||||
{ value: 'OTRO', label: 'Otro' },
|
||||
]
|
||||
|
||||
const ESTADO_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'DESCONOCIDO', label: 'Desconocido' },
|
||||
{ value: 'ONLINE', label: 'En línea' },
|
||||
{ value: 'OFFLINE', label: 'Fuera de línea' },
|
||||
{ value: 'ALERTA', label: 'Advertencia' },
|
||||
{ value: 'MANTENIMIENTO', label: 'Mantenimiento' },
|
||||
]
|
||||
|
||||
export interface AddDeviceFormValues {
|
||||
tipo: string
|
||||
nombre: string
|
||||
descripcion: string
|
||||
ubicacionId: string
|
||||
estado: string
|
||||
ip: string
|
||||
mac: string
|
||||
sistemaOperativo: string
|
||||
versionSO: string
|
||||
fabricante: string
|
||||
modelo: string
|
||||
serial: string
|
||||
}
|
||||
|
||||
const initialValues: AddDeviceFormValues = {
|
||||
tipo: 'PC',
|
||||
nombre: '',
|
||||
descripcion: '',
|
||||
ubicacionId: '',
|
||||
estado: 'DESCONOCIDO',
|
||||
ip: '',
|
||||
mac: '',
|
||||
sistemaOperativo: '',
|
||||
versionSO: '',
|
||||
fabricante: '',
|
||||
modelo: '',
|
||||
serial: '',
|
||||
}
|
||||
|
||||
interface AddDeviceModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
clienteId: string | undefined
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 placeholder-gray-500 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20'
|
||||
|
||||
export default function AddDeviceModal({
|
||||
open,
|
||||
onClose,
|
||||
clienteId,
|
||||
onSuccess,
|
||||
}: AddDeviceModalProps) {
|
||||
const [form, setForm] = useState<AddDeviceFormValues>(initialValues)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const ubicacionesQuery = trpc.clientes.ubicaciones.list.useQuery(
|
||||
{ clienteId: clienteId! },
|
||||
{ enabled: open && !!clienteId }
|
||||
)
|
||||
const createMutation = trpc.equipos.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.equipos.list.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
onSuccess?.()
|
||||
handleClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
setSubmitError(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setForm(initialValues)
|
||||
setSubmitError(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleClose = () => {
|
||||
setSubmitError(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSubmitError(null)
|
||||
if (!clienteId) {
|
||||
setSubmitError('Seleccione un cliente.')
|
||||
return
|
||||
}
|
||||
createMutation.mutate({
|
||||
clienteId,
|
||||
tipo: form.tipo as AddDeviceFormValues['tipo'],
|
||||
nombre: form.nombre.trim(),
|
||||
descripcion: form.descripcion.trim() || undefined,
|
||||
ubicacionId: form.ubicacionId || undefined,
|
||||
estado: form.estado as AddDeviceFormValues['estado'],
|
||||
ip: form.ip.trim() || undefined,
|
||||
mac: form.mac.trim() || undefined,
|
||||
sistemaOperativo: form.sistemaOperativo.trim() || undefined,
|
||||
versionSO: form.versionSO.trim() || undefined,
|
||||
fabricante: form.fabricante.trim() || undefined,
|
||||
modelo: form.modelo.trim() || undefined,
|
||||
serial: form.serial.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-device-title"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-xl border border-white/10 bg-dark-400 shadow-xl',
|
||||
'flex flex-col'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||
<h2 id="add-device-title" className="text-lg font-semibold text-white">
|
||||
Agregar Dispositivo
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 p-4 gap-4">
|
||||
{submitError && (
|
||||
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-2 text-sm text-red-400">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!clienteId && (
|
||||
<p className="text-sm text-amber-400">
|
||||
Seleccione un cliente en el selector del header para poder agregar dispositivos.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Tipo *
|
||||
</label>
|
||||
<select
|
||||
value={form.tipo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tipo: e.target.value }))}
|
||||
className={inputClass}
|
||||
required
|
||||
>
|
||||
{TIPO_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Nombre *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Ej: PC-Oficina-01"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={form.descripcion}
|
||||
onChange={(e) => setForm((f) => ({ ...f, descripcion: e.target.value }))}
|
||||
className={cn(inputClass, 'min-h-[80px] resize-y')}
|
||||
placeholder="Opcional"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Ubicación
|
||||
</label>
|
||||
<select
|
||||
value={form.ubicacionId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ubicacionId: e.target.value }))}
|
||||
className={inputClass}
|
||||
disabled={!clienteId}
|
||||
>
|
||||
<option value="">Sin ubicación</option>
|
||||
{(ubicacionesQuery.data ?? []).map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Estado inicial
|
||||
</label>
|
||||
<select
|
||||
value={form.estado}
|
||||
onChange={(e) => setForm((f) => ({ ...f, estado: e.target.value }))}
|
||||
className={inputClass}
|
||||
>
|
||||
{ESTADO_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
IP
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.ip}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ip: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="192.168.1.10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
MAC
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.mac}
|
||||
onChange={(e) => setForm((f) => ({ ...f, mac: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Sistema operativo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.sistemaOperativo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, sistemaOperativo: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Windows 11, Ubuntu, etc."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Versión SO
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.versionSO}
|
||||
onChange={(e) => setForm((f) => ({ ...f, versionSO: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Fabricante
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.fabricante}
|
||||
onChange={(e) => setForm((f) => ({ ...f, fabricante: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Dell, HP, Cisco..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Modelo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.modelo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, modelo: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||
Número de serie
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.serial}
|
||||
onChange={(e) => setForm((f) => ({ ...f, serial: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!clienteId || createMutation.isPending}
|
||||
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createMutation.isPending ? 'Creando...' : 'Crear dispositivo'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
src/components/devices/DeviceCard.tsx
Normal file
143
src/components/devices/DeviceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/components/devices/device-detail/ActionBar.tsx
Normal file
52
src/components/devices/device-detail/ActionBar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
// TODO: wire actions to MeshCentral remote session (equipos.iniciarSesion) and /terminal, /archivos routes
|
||||
import { ExternalLink, Terminal, FolderOpen } from 'lucide-react'
|
||||
|
||||
interface ActionBarProps {
|
||||
deviceId: string
|
||||
onConnect?: (id: string) => void
|
||||
onTerminal?: (id: string) => void
|
||||
onFiles?: (id: string) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function ActionBar({
|
||||
deviceId,
|
||||
onConnect,
|
||||
onTerminal,
|
||||
onFiles,
|
||||
loading = false,
|
||||
}: ActionBarProps) {
|
||||
return (
|
||||
<div className="sticky bottom-0 left-0 right-0 flex flex-wrap items-center gap-2 border-t border-white/10 bg-dark-400/95 backdrop-blur px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConnect?.(deviceId)}
|
||||
disabled={loading}
|
||||
className="btn btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{loading ? 'Conectando...' : 'Conectar Escritorio'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTerminal?.(deviceId)}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary inline-flex items-center gap-2"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFiles?.(deviceId)}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary inline-flex items-center gap-2"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Archivos
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
478
src/components/devices/device-detail/DeviceDetailModal.tsx
Normal file
478
src/components/devices/device-detail/DeviceDetailModal.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Monitor,
|
||||
Laptop,
|
||||
Server,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Router,
|
||||
Network,
|
||||
Shield,
|
||||
Wifi,
|
||||
Printer,
|
||||
HelpCircle,
|
||||
X,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { mapDeviceToDetail } from '@/mocks/deviceDetailData'
|
||||
import InfoSection from './InfoSection'
|
||||
import SoftwareList from './SoftwareList'
|
||||
import ActionBar from './ActionBar'
|
||||
|
||||
const DEVICE_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
PC: <Monitor className="h-6 w-6" />,
|
||||
LAPTOP: <Laptop className="h-6 w-6" />,
|
||||
SERVIDOR: <Server className="h-6 w-6" />,
|
||||
CELULAR: <Smartphone className="h-6 w-6" />,
|
||||
TABLET: <Tablet className="h-6 w-6" />,
|
||||
ROUTER: <Router className="h-6 w-6" />,
|
||||
SWITCH: <Network className="h-6 w-6" />,
|
||||
FIREWALL: <Shield className="h-6 w-6" />,
|
||||
AP: <Wifi className="h-6 w-6" />,
|
||||
IMPRESORA: <Printer className="h-6 w-6" />,
|
||||
OTRO: <HelpCircle className="h-6 w-6" />,
|
||||
}
|
||||
|
||||
const DEVICE_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'PC', label: 'PC' },
|
||||
{ value: 'LAPTOP', label: 'Laptop' },
|
||||
{ value: 'SERVIDOR', label: 'Servidor' },
|
||||
{ value: 'CELULAR', label: 'Celular' },
|
||||
{ value: 'TABLET', label: 'Tablet' },
|
||||
{ value: 'ROUTER', label: 'Router' },
|
||||
{ value: 'SWITCH', label: 'Switch' },
|
||||
{ value: 'FIREWALL', label: 'Firewall' },
|
||||
{ value: 'AP', label: 'Access Point' },
|
||||
{ value: 'IMPRESORA', label: 'Impresora' },
|
||||
{ value: 'OTRO', label: 'Otro' },
|
||||
]
|
||||
|
||||
const DEVICE_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'DESCONOCIDO', label: 'Desconocido' },
|
||||
{ value: 'ONLINE', label: 'En línea' },
|
||||
{ value: 'OFFLINE', label: 'Fuera de línea' },
|
||||
{ value: 'ALERTA', label: 'Advertencia' },
|
||||
{ value: 'MANTENIMIENTO', label: 'Mantenimiento' },
|
||||
]
|
||||
|
||||
const INPUT_CLASS =
|
||||
'w-full rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 placeholder-gray-500 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20'
|
||||
|
||||
type DeviceTypeValue =
|
||||
| 'PC'
|
||||
| 'LAPTOP'
|
||||
| 'SERVIDOR'
|
||||
| 'CELULAR'
|
||||
| 'TABLET'
|
||||
| 'ROUTER'
|
||||
| 'SWITCH'
|
||||
| 'FIREWALL'
|
||||
| 'AP'
|
||||
| 'IMPRESORA'
|
||||
| 'OTRO'
|
||||
|
||||
type DeviceStatusValue = 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO'
|
||||
|
||||
interface EditFormState {
|
||||
tipo: string
|
||||
nombre: string
|
||||
descripcion: string
|
||||
ubicacionId: string
|
||||
estado: string
|
||||
ip: string
|
||||
mac: string
|
||||
sistemaOperativo: string
|
||||
versionSO: string
|
||||
fabricante: string
|
||||
modelo: string
|
||||
serial: string
|
||||
}
|
||||
|
||||
interface DeviceFromApi {
|
||||
tipo: string
|
||||
nombre: string
|
||||
descripcion?: string | null
|
||||
ubicacionId?: string | null
|
||||
estado: string
|
||||
ip?: string | null
|
||||
mac?: string | null
|
||||
sistemaOperativo?: string | null
|
||||
versionSO?: string | null
|
||||
fabricante?: string | null
|
||||
modelo?: string | null
|
||||
serial?: string | null
|
||||
}
|
||||
|
||||
function buildEditFormFromDevice(device: DeviceFromApi): EditFormState {
|
||||
return {
|
||||
tipo: device.tipo,
|
||||
nombre: device.nombre,
|
||||
descripcion: device.descripcion ?? '',
|
||||
ubicacionId: device.ubicacionId ?? '',
|
||||
estado: device.estado,
|
||||
ip: device.ip ?? '',
|
||||
mac: device.mac ?? '',
|
||||
sistemaOperativo: device.sistemaOperativo ?? '',
|
||||
versionSO: device.versionSO ?? '',
|
||||
fabricante: device.fabricante ?? '',
|
||||
modelo: device.modelo ?? '',
|
||||
serial: device.serial ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
interface DeviceDetailModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
deviceId: string | null
|
||||
deviceName?: string
|
||||
onConnect?: (id: string) => void
|
||||
onTerminal?: (id: string) => void
|
||||
onFiles?: (id: string) => void
|
||||
}
|
||||
|
||||
export default function DeviceDetailModal({
|
||||
open,
|
||||
onClose,
|
||||
deviceId,
|
||||
deviceName = 'Dispositivo',
|
||||
onConnect,
|
||||
onTerminal,
|
||||
onFiles,
|
||||
}: DeviceDetailModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editForm, setEditForm] = useState<EditFormState | null>(null)
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const deviceQuery = trpc.equipos.byId.useQuery(
|
||||
{ id: deviceId! },
|
||||
{ enabled: open && !!deviceId }
|
||||
)
|
||||
const device = deviceQuery.data
|
||||
const clientId = device?.clienteId
|
||||
|
||||
const locationsQuery = trpc.clientes.ubicaciones.list.useQuery(
|
||||
{ clienteId: clientId! },
|
||||
{ enabled: open && !!clientId && isEditing }
|
||||
)
|
||||
|
||||
const updateMutation = trpc.equipos.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.equipos.byId.invalidate({ id: deviceId! })
|
||||
utils.equipos.list.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
setIsEditing(false)
|
||||
setEditForm(null)
|
||||
setEditError(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
setEditError(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIsEditing(false)
|
||||
setEditForm(null)
|
||||
setEditError(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && device) {
|
||||
setEditForm(buildEditFormFromDevice(device))
|
||||
setEditError(null)
|
||||
}
|
||||
}, [isEditing, device])
|
||||
|
||||
const detail = device ? mapDeviceToDetail(device) : null
|
||||
const isLoading = deviceQuery.isLoading
|
||||
const hasError = deviceQuery.isError
|
||||
|
||||
const systemItems = detail
|
||||
? [
|
||||
{ label: 'Sistema Operativo', value: detail.systemInfo.sistemaOperativo },
|
||||
{ label: 'Procesador', value: detail.systemInfo.procesador },
|
||||
{ label: 'Memoria RAM', value: detail.systemInfo.memoriaRam },
|
||||
{
|
||||
label: 'Almacenamiento',
|
||||
value:
|
||||
detail.systemInfo.almacenamientoUsoPercent != null
|
||||
? `${detail.systemInfo.almacenamiento} (${detail.systemInfo.almacenamientoUsoPercent}% uso)`
|
||||
: detail.systemInfo.almacenamiento,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const networkItems = detail
|
||||
? [
|
||||
{ label: 'Dirección IP', value: detail.networkInfo.direccionIp },
|
||||
{ label: 'Dirección MAC', value: detail.networkInfo.direccionMac },
|
||||
{ label: 'Gateway', value: detail.networkInfo.gateway },
|
||||
{ label: 'DNS', value: detail.networkInfo.dns },
|
||||
]
|
||||
: []
|
||||
|
||||
const handleSaveEdit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!deviceId || !editForm) return
|
||||
setEditError(null)
|
||||
updateMutation.mutate({
|
||||
id: deviceId,
|
||||
tipo: editForm.tipo as DeviceTypeValue,
|
||||
nombre: editForm.nombre.trim(),
|
||||
descripcion: editForm.descripcion.trim() || null,
|
||||
ubicacionId: editForm.ubicacionId || null,
|
||||
estado: editForm.estado as DeviceStatusValue,
|
||||
ip: editForm.ip.trim() || null,
|
||||
mac: editForm.mac.trim() || null,
|
||||
sistemaOperativo: editForm.sistemaOperativo.trim() || null,
|
||||
versionSO: editForm.versionSO.trim() || null,
|
||||
fabricante: editForm.fabricante.trim() || null,
|
||||
modelo: editForm.modelo.trim() || null,
|
||||
serial: editForm.serial.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="device-detail-title"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full max-w-2xl max-h-[90vh] overflow-hidden rounded-xl border border-white/10 bg-dark-400 shadow-xl',
|
||||
'flex flex-col'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-dark-300 border border-white/10 text-gray-400">
|
||||
{detail ? DEVICE_TYPE_ICONS[detail.tipo] ?? DEVICE_TYPE_ICONS.OTRO : DEVICE_TYPE_ICONS.OTRO}
|
||||
</div>
|
||||
<h2
|
||||
id="device-detail-title"
|
||||
className="text-lg font-semibold text-white truncate"
|
||||
>
|
||||
{detail?.nombre ?? deviceName}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{detail && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-primary-400 transition-colors"
|
||||
aria-label="Editar dispositivo"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{isLoading && !editForm && (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Cargando información del dispositivo...
|
||||
</div>
|
||||
)}
|
||||
{hasError && (
|
||||
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-3 text-sm text-red-400">
|
||||
No se pudo cargar el dispositivo. Intente de nuevo.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && editForm && device && (
|
||||
<form onSubmit={handleSaveEdit} className="space-y-4">
|
||||
{editError && (
|
||||
<div className="rounded-lg bg-red-500/20 border border-red-500/40 px-4 py-2 text-sm text-red-400">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Tipo</label>
|
||||
<select
|
||||
value={editForm.tipo}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, tipo: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{DEVICE_TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Nombre *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.nombre}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, nombre: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Descripción</label>
|
||||
<textarea
|
||||
value={editForm.descripcion}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, descripcion: e.target.value })}
|
||||
className={cn(INPUT_CLASS, 'min-h-[80px] resize-y')}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Ubicación</label>
|
||||
<select
|
||||
value={editForm.ubicacionId}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, ubicacionId: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
<option value="">Sin ubicación</option>
|
||||
{(locationsQuery.data ?? []).map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>{loc.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Estado</label>
|
||||
<select
|
||||
value={editForm.estado}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, estado: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{DEVICE_STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">IP</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.ip}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, ip: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">MAC</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.mac}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, mac: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Sistema operativo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.sistemaOperativo}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, sistemaOperativo: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Versión SO</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.versionSO}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, versionSO: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Fabricante</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.fabricante}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, fabricante: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Modelo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.modelo}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, modelo: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">Número de serie</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.serial}
|
||||
onChange={(e) => setEditForm((f) => f && { ...f, serial: e.target.value })}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updateMutation.isPending ? 'Guardando...' : 'Guardar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{detail && !isLoading && !isEditing && (
|
||||
<>
|
||||
<InfoSection title="Información del sistema" items={systemItems} />
|
||||
<InfoSection title="Información de red" items={networkItems} />
|
||||
<SoftwareList items={detail.software} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail && !isEditing && (
|
||||
<ActionBar
|
||||
deviceId={detail.id}
|
||||
onConnect={onConnect}
|
||||
onTerminal={onTerminal}
|
||||
onFiles={onFiles}
|
||||
loading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/devices/device-detail/InfoCard.tsx
Normal file
19
src/components/devices/device-detail/InfoCard.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
interface InfoCardProps {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export default function InfoCard({ label, value }: InfoCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/10 bg-dark-300/80 px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-100 truncate" title={value}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/components/devices/device-detail/InfoSection.tsx
Normal file
21
src/components/devices/device-detail/InfoSection.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import InfoCard from './InfoCard'
|
||||
|
||||
interface InfoSectionProps {
|
||||
title: string
|
||||
items: { label: string; value: string }[]
|
||||
}
|
||||
|
||||
export default function InfoSection({ title, items }: InfoSectionProps) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-300">{title}</h3>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<InfoCard key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
36
src/components/devices/device-detail/SoftwareList.tsx
Normal file
36
src/components/devices/device-detail/SoftwareList.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import type { InstalledSoftware } from '@/mocks/deviceDetailData'
|
||||
|
||||
interface SoftwareListProps {
|
||||
items: InstalledSoftware[]
|
||||
}
|
||||
|
||||
export default function SoftwareList({ items }: SoftwareListProps) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-300">
|
||||
Software instalado
|
||||
</h3>
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-white/10 bg-dark-300/80 divide-y divide-white/5">
|
||||
{items.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-500">
|
||||
Sin información de software
|
||||
</div>
|
||||
) : (
|
||||
items.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-gray-200 truncate">{s.nombre}</span>
|
||||
<span className="text-xs text-gray-500 shrink-0 tabular-nums">
|
||||
{s.version}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
91
src/components/files/FileExplorerContainer.tsx
Normal file
91
src/components/files/FileExplorerContainer.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { FolderOpen, Upload, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FileItem } from './FileRow'
|
||||
import FileTable from './FileTable'
|
||||
|
||||
interface FileExplorerContainerProps {
|
||||
selectedDeviceName: string | null
|
||||
currentPath: string
|
||||
files: FileItem[]
|
||||
onFolderClick?: (name: string) => void
|
||||
onRefresh?: () => void
|
||||
onUpload?: () => void
|
||||
}
|
||||
|
||||
export default function FileExplorerContainer({
|
||||
selectedDeviceName,
|
||||
currentPath,
|
||||
files,
|
||||
onFolderClick,
|
||||
onRefresh,
|
||||
onUpload,
|
||||
}: FileExplorerContainerProps) {
|
||||
const pathRef = useRef<HTMLDivElement>(null)
|
||||
const hasDevice = !!selectedDeviceName
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[520px] min-h-[320px] flex-col overflow-hidden rounded-xl',
|
||||
'border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'shadow-lg'
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-white/10 bg-dark-200/80 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpload}
|
||||
disabled={!hasDevice}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Subir
|
||||
</button>
|
||||
<div
|
||||
ref={pathRef}
|
||||
className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-white/10 bg-dark-300/80 px-3 py-2 font-mono text-sm text-gray-400"
|
||||
>
|
||||
{currentPath}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={!hasDevice}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-sm text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Actualizar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpload}
|
||||
disabled={!hasDevice}
|
||||
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Subir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{!hasDevice ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
|
||||
<FolderOpen className="h-16 w-16 text-gray-600" />
|
||||
<p className="text-gray-500">
|
||||
Selecciona un dispositivo para explorar archivos
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<FileTable files={files} onFolderClick={onFolderClick} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/components/files/FileRow.tsx
Normal file
50
src/components/files/FileRow.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { Folder, FileText } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface FileItem {
|
||||
id: string
|
||||
name: string
|
||||
type: 'folder' | 'file'
|
||||
size: string | null
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
interface FileRowProps {
|
||||
file: FileItem
|
||||
onFolderClick?: (name: string) => void
|
||||
}
|
||||
|
||||
export default function FileRow({ file, onFolderClick }: FileRowProps) {
|
||||
const isFolder = file.type === 'folder'
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
'border-b border-white/5 transition-colors last:border-b-0',
|
||||
'hover:bg-white/5 cursor-pointer',
|
||||
isFolder && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => isFolder && onFolderClick?.(file.name)}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{isFolder ? (
|
||||
<Folder className="h-5 w-5 shrink-0 text-amber-400/90" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<span className="font-medium text-gray-200 truncate">{file.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500 font-mono">
|
||||
{file.size ?? '—'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">
|
||||
{isFolder ? 'Carpeta' : 'Archivo'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">{file.modifiedAt}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
35
src/components/files/FileTable.tsx
Normal file
35
src/components/files/FileTable.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import type { FileItem } from './FileRow'
|
||||
import FileRow from './FileRow'
|
||||
|
||||
interface FileTableProps {
|
||||
files: FileItem[]
|
||||
onFolderClick?: (name: string) => void
|
||||
}
|
||||
|
||||
export default function FileTable({ files, onFolderClick }: FileTableProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-dark-300/95 backdrop-blur">
|
||||
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th className="py-3 px-4">Nombre</th>
|
||||
<th className="py-3 px-4 w-28">Tamaño</th>
|
||||
<th className="py-3 px-4 w-24">Tipo</th>
|
||||
<th className="py-3 px-4 w-36">Fecha modificación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<FileRow
|
||||
key={file.id}
|
||||
file={file}
|
||||
onFolderClick={onFolderClick}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/components/headwind/CorporateAppRow.tsx
Normal file
28
src/components/headwind/CorporateAppRow.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AppDeployment } from '@/mocks/mdmDashboardData'
|
||||
|
||||
interface CorporateAppRowProps {
|
||||
app: AppDeployment
|
||||
}
|
||||
|
||||
export default function CorporateAppRow({ app }: CorporateAppRowProps) {
|
||||
const isIncomplete = app.deployed < app.total
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-200">{app.name}</p>
|
||||
<p className="text-sm text-gray-500">v{app.version}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 text-sm tabular-nums',
|
||||
isIncomplete ? 'text-amber-400' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
{app.deployed}/{app.total} dispositivos
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/components/headwind/CorporateAppsList.tsx
Normal file
33
src/components/headwind/CorporateAppsList.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDeployment } from '@/mocks/mdmDashboardData'
|
||||
import CorporateAppRow from './CorporateAppRow'
|
||||
|
||||
interface CorporateAppsListProps {
|
||||
apps: AppDeployment[]
|
||||
}
|
||||
|
||||
export default function CorporateAppsList({ apps }: CorporateAppsListProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Apps Corporativas
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{apps.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Sin apps desplegadas
|
||||
</div>
|
||||
) : (
|
||||
apps.map((app) => (
|
||||
<div key={app.id} className="px-4">
|
||||
<CorporateAppRow app={app} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/headwind/DeviceItem.tsx
Normal file
38
src/components/headwind/DeviceItem.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Device, DeviceStatus } from '@/mocks/mdmDashboardData'
|
||||
|
||||
const statusConfig: Record<DeviceStatus, { dot: string; text: string }> = {
|
||||
online: { dot: 'bg-green-500', text: 'text-green-400' },
|
||||
offline: { dot: 'bg-red-500', text: 'text-red-400' },
|
||||
kiosk: { dot: 'bg-blue-500', text: 'text-blue-400' },
|
||||
}
|
||||
|
||||
const statusLabel: Record<DeviceStatus, string> = {
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
kiosk: 'Kiosk',
|
||||
}
|
||||
|
||||
interface DeviceItemProps {
|
||||
device: Device
|
||||
}
|
||||
|
||||
export default function DeviceItem({ device }: DeviceItemProps) {
|
||||
const config = statusConfig[device.status]
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-200">{device.name}</p>
|
||||
<p className="mt-0.5 text-sm text-gray-500">
|
||||
Android {device.androidVersion} · Batería {device.batteryPercent}%
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn('ml-4 inline-flex items-center gap-1.5 text-sm shrink-0', config.text)}>
|
||||
<span className={cn('h-2 w-2 rounded-full', config.dot)} />
|
||||
{statusLabel[device.status]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/headwind/HeadwindMetricCard.tsx
Normal file
45
src/components/headwind/HeadwindMetricCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AccentColor = 'green' | 'blue' | 'cyan' | 'amber'
|
||||
|
||||
interface HeadwindMetricCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
subtitle: string
|
||||
accent?: AccentColor
|
||||
}
|
||||
|
||||
const accentColors: Record<AccentColor, string> = {
|
||||
green: 'text-emerald-400',
|
||||
blue: 'text-blue-400',
|
||||
cyan: 'text-cyan-400',
|
||||
amber: 'text-amber-400',
|
||||
}
|
||||
|
||||
export default function HeadwindMetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
accent = 'cyan',
|
||||
}: HeadwindMetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 overflow-hidden',
|
||||
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'flex flex-col justify-center px-5 py-6',
|
||||
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/components/headwind/MobileDeviceList.tsx
Normal file
33
src/components/headwind/MobileDeviceList.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { Device } from '@/mocks/mdmDashboardData'
|
||||
import DeviceItem from './DeviceItem'
|
||||
|
||||
interface MobileDeviceListProps {
|
||||
devices: Device[]
|
||||
}
|
||||
|
||||
export default function MobileDeviceList({ devices }: MobileDeviceListProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Dispositivos Móviles
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{devices.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Sin dispositivos
|
||||
</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<div key={device.id} className="px-4">
|
||||
<DeviceItem device={device} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/',
|
||||
icon: <LayoutDashboard className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Equipos',
|
||||
href: '/equipos',
|
||||
icon: <Monitor className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Celulares',
|
||||
href: '/celulares',
|
||||
icon: <Smartphone className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Red',
|
||||
href: '/red',
|
||||
icon: <Network className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Alertas',
|
||||
href: '/alertas',
|
||||
icon: <AlertTriangle className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Reportes',
|
||||
href: '/reportes',
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
},
|
||||
]
|
||||
export interface SidebarMenuSection {
|
||||
label: string
|
||||
items: SidebarMenuItem[]
|
||||
}
|
||||
|
||||
const adminItems: NavItem[] = [
|
||||
const menuConfig: SidebarMenuSection[] = [
|
||||
{
|
||||
label: 'Clientes',
|
||||
href: '/clientes',
|
||||
icon: <Building2 className="w-5 h-5" />,
|
||||
label: 'PRINCIPAL',
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard className="w-5 h-5" /> },
|
||||
{
|
||||
label: 'Dispositivos',
|
||||
href: '/devices',
|
||||
icon: <Monitor className="w-5 h-5" />,
|
||||
badge: { type: 'red', value: 10 },
|
||||
},
|
||||
{
|
||||
label: 'Sesiones',
|
||||
href: '/sesiones',
|
||||
icon: <Video className="w-5 h-5" />,
|
||||
badge: { type: 'red', value: 4 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Usuarios',
|
||||
href: '/usuarios',
|
||||
icon: <Users className="w-5 h-5" />,
|
||||
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: 'Configuracion',
|
||||
href: '/configuracion',
|
||||
icon: <Settings 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: '/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: '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 (
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
{/* 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>
|
||||
</Link>
|
||||
<>
|
||||
{/* 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'
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="p-1.5 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
||||
{items.map((item) => (
|
||||
<aside
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-slate-800/80 px-4">
|
||||
<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}
|
||||
href="/"
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-slate-700/30"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<Activity className="h-8 w-8 text-cyan-400" />
|
||||
<span className="text-lg font-semibold text-white">MSP Monitor</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* 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}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && <span className="flex-1">{item.label}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</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>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<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}
|
||||
icon={item.icon}
|
||||
active={isActive(item.href)}
|
||||
badge={getBadgeValue(item)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
))}
|
||||
</SidebarSection>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
69
src/components/layout/SidebarItem.tsx
Normal file
69
src/components/layout/SidebarItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/components/layout/SidebarSection.tsx
Normal file
24
src/components/layout/SidebarSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/components/librenms/AlertItem.tsx
Normal file
33
src/components/librenms/AlertItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type AlertSeverity = 'critical' | 'warning' | 'info'
|
||||
|
||||
export interface SnmpAlert {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
severity: AlertSeverity
|
||||
}
|
||||
|
||||
const severityStyles: Record<AlertSeverity, string> = {
|
||||
critical: 'text-red-400',
|
||||
warning: 'text-yellow-400',
|
||||
info: 'text-blue-400',
|
||||
}
|
||||
|
||||
interface AlertItemProps {
|
||||
alert: SnmpAlert
|
||||
}
|
||||
|
||||
export default function AlertItem({ alert }: AlertItemProps) {
|
||||
return (
|
||||
<div className="border-b border-white/5 py-4 last:border-b-0">
|
||||
<p className={cn('font-medium', severityStyles[alert.severity])}>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{alert.detail}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/components/librenms/AlertList.tsx
Normal file
31
src/components/librenms/AlertList.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import type { SnmpAlert } from './AlertItem'
|
||||
import AlertItem from './AlertItem'
|
||||
|
||||
interface AlertListProps {
|
||||
alerts: SnmpAlert[]
|
||||
}
|
||||
|
||||
export default function AlertList({ alerts }: AlertListProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Alertas SNMP Recientes
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Sin alertas
|
||||
</div>
|
||||
) : (
|
||||
alerts.map((alert) => (
|
||||
<AlertItem key={alert.id} alert={alert} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/components/librenms/DeviceList.tsx
Normal file
33
src/components/librenms/DeviceList.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { NetworkDevice } from './DeviceRow'
|
||||
import DeviceRow from './DeviceRow'
|
||||
|
||||
interface DeviceListProps {
|
||||
devices: NetworkDevice[]
|
||||
}
|
||||
|
||||
export default function DeviceList({ devices }: DeviceListProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 overflow-hidden bg-gradient-to-b from-dark-300/90 to-dark-400/90">
|
||||
<div className="border-b border-white/10 px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Dispositivos de Red
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{devices.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
Sin dispositivos
|
||||
</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<div key={device.id} className="px-4">
|
||||
<DeviceRow device={device} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/components/librenms/DeviceRow.tsx
Normal file
37
src/components/librenms/DeviceRow.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import type { DeviceStatus } from './StatusBadge'
|
||||
import StatusBadge from './StatusBadge'
|
||||
|
||||
export interface NetworkDevice {
|
||||
id: string
|
||||
name: string
|
||||
model?: string
|
||||
status: DeviceStatus
|
||||
}
|
||||
|
||||
const statusLabel: Record<DeviceStatus, string> = {
|
||||
online: 'Online',
|
||||
warning: 'Warning',
|
||||
critical: 'Critical',
|
||||
}
|
||||
|
||||
interface DeviceRowProps {
|
||||
device: NetworkDevice
|
||||
}
|
||||
|
||||
export default function DeviceRow({ device }: DeviceRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-white/5 py-3 last:border-b-0 transition-colors hover:bg-white/5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium text-gray-200">{device.name}</span>
|
||||
{device.model && (
|
||||
<span className="ml-2 text-sm text-gray-500">– {device.model}</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={device.status} label={statusLabel[device.status]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/librenms/LibrenmsMetricCard.tsx
Normal file
45
src/components/librenms/LibrenmsMetricCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type AccentColor = 'green' | 'cyan' | 'yellow' | 'blue'
|
||||
|
||||
interface LibrenmsMetricCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
subtitle: string
|
||||
accent?: AccentColor
|
||||
}
|
||||
|
||||
const accentColors: Record<AccentColor, string> = {
|
||||
green: 'text-emerald-400',
|
||||
cyan: 'text-cyan-400',
|
||||
yellow: 'text-amber-400',
|
||||
blue: 'text-blue-400',
|
||||
}
|
||||
|
||||
export default function LibrenmsMetricCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
accent = 'cyan',
|
||||
}: LibrenmsMetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 overflow-hidden',
|
||||
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'flex flex-col justify-center px-5 py-6',
|
||||
'transition-all duration-200 hover:border-white/20 hover:shadow-lg'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn('mt-1 text-4xl font-bold tabular-nums', accentColors[accent])}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-1 text-sm text-gray-400">{subtitle}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/components/librenms/LibrenmsRow.tsx
Normal file
49
src/components/librenms/LibrenmsRow.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface LibrenmsNodeItem {
|
||||
id: string
|
||||
name: string
|
||||
ip: string
|
||||
type: string
|
||||
status: 'up' | 'down' | 'warning'
|
||||
lastUpdate: string
|
||||
}
|
||||
|
||||
interface LibrenmsRowProps {
|
||||
item: LibrenmsNodeItem
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
up: { label: 'En línea', dot: 'bg-emerald-500' },
|
||||
down: { label: 'Fuera de línea', dot: 'bg-red-500' },
|
||||
warning: { label: 'Alerta', dot: 'bg-amber-500' },
|
||||
}
|
||||
|
||||
export default function LibrenmsRow({ item }: LibrenmsRowProps) {
|
||||
const status = statusConfig[item.status]
|
||||
|
||||
return (
|
||||
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5 cursor-pointer">
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium text-gray-200">{item.name}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-sm text-gray-400">
|
||||
{item.ip}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="rounded-full bg-cyan-500/10 px-3 py-1 text-xs text-cyan-400">
|
||||
{item.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('h-2 w-2 rounded-full', status.dot)} />
|
||||
<span className="text-sm text-gray-400">{status.label}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
26
src/components/librenms/StatusBadge.tsx
Normal file
26
src/components/librenms/StatusBadge.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type DeviceStatus = 'online' | 'warning' | 'critical'
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: DeviceStatus
|
||||
label: string
|
||||
}
|
||||
|
||||
const statusConfig: Record<DeviceStatus, { dot: string; text: string }> = {
|
||||
online: { dot: 'bg-green-500', text: 'text-green-400' },
|
||||
warning: { dot: 'bg-yellow-500', text: 'text-yellow-400' },
|
||||
critical: { dot: 'bg-red-500', text: 'text-red-400' },
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status, label }: StatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', config.text)}>
|
||||
<span className={cn('h-2 w-2 shrink-0 rounded-full', config.dot)} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
57
src/components/performance/LineChart.tsx
Normal file
57
src/components/performance/LineChart.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LineChartProps {
|
||||
className?: string
|
||||
height?: number
|
||||
data?: number[]
|
||||
}
|
||||
|
||||
export default function LineChart({
|
||||
className,
|
||||
height = 160,
|
||||
data = [],
|
||||
}: LineChartProps) {
|
||||
const points = data.length >= 2
|
||||
? data
|
||||
.map((v, i) => {
|
||||
const x = (i / Math.max(1, data.length - 1)) * 100
|
||||
const y = 100 - Math.min(100, Math.max(0, v))
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
: '0,100 100,100'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg bg-dark-200/80 border border-white/5 overflow-hidden',
|
||||
className
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{data.length >= 2 ? (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="w-full h-full text-cyan-500/30"
|
||||
>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={points}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-600 text-sm">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
src/components/performance/MetricCard.tsx
Normal file
72
src/components/performance/MetricCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import LineChart from './LineChart'
|
||||
|
||||
export interface MetricCardFooterRow {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
value: string
|
||||
valueSuffix?: string
|
||||
footerStats: MetricCardFooterRow[]
|
||||
chartData?: number[]
|
||||
highUsage?: boolean
|
||||
}
|
||||
|
||||
export default function MetricCard({
|
||||
title,
|
||||
value,
|
||||
valueSuffix,
|
||||
footerStats,
|
||||
chartData,
|
||||
highUsage = false,
|
||||
}: MetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-xl border border-white/10 overflow-hidden',
|
||||
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'transition-all duration-200 hover:border-white/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between px-4 py-3 border-b border-white/5">
|
||||
<span className="text-sm font-medium text-gray-400">{title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-lg font-semibold tabular-nums transition-colors duration-300',
|
||||
highUsage ? 'text-red-400' : 'text-cyan-400'
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
{valueSuffix != null && (
|
||||
<span className="text-sm font-normal text-gray-500 ml-0.5">
|
||||
{valueSuffix}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-4 pt-3">
|
||||
<LineChart height={150} data={chartData ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-1.5 px-4 py-3 border-t border-white/5">
|
||||
{footerStats.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex justify-between text-xs"
|
||||
>
|
||||
<span className="text-gray-500">{row.label}</span>
|
||||
<span className="font-mono text-gray-400 tabular-nums">
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/performance/ProcessRow.tsx
Normal file
36
src/components/performance/ProcessRow.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ProcessItem {
|
||||
id: string
|
||||
name: string
|
||||
pid: number
|
||||
cpu: number
|
||||
memory: string
|
||||
state: string
|
||||
}
|
||||
|
||||
interface ProcessRowProps {
|
||||
process: ProcessItem
|
||||
}
|
||||
|
||||
export default function ProcessRow({ process }: ProcessRowProps) {
|
||||
return (
|
||||
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
|
||||
<td className="py-2.5 px-4 text-sm text-gray-200 truncate max-w-[200px]">
|
||||
{process.name}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 font-mono text-sm text-gray-400 tabular-nums">
|
||||
{process.pid}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 font-mono text-sm text-cyan-400 tabular-nums">
|
||||
{process.cpu}%
|
||||
</td>
|
||||
<td className="py-2.5 px-4 font-mono text-sm text-gray-400">
|
||||
{process.memory}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-sm text-gray-500">{process.state}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
65
src/components/performance/ProcessTable.tsx
Normal file
65
src/components/performance/ProcessTable.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { ProcessItem } from './ProcessRow'
|
||||
import ProcessRow from './ProcessRow'
|
||||
|
||||
interface ProcessTableProps {
|
||||
processes: ProcessItem[]
|
||||
noDevice?: boolean
|
||||
}
|
||||
|
||||
export default function ProcessTable({ processes, noDevice }: ProcessTableProps) {
|
||||
if (noDevice) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Procesos Activos (Top 10 por CPU)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
Selecciona un dispositivo
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-dark-300/50 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Procesos Activos (Top 10 por CPU)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th className="py-3 px-4">Proceso</th>
|
||||
<th className="py-3 px-4 w-20">PID</th>
|
||||
<th className="py-3 px-4 w-24">CPU %</th>
|
||||
<th className="py-3 px-4 w-24">Memoria</th>
|
||||
<th className="py-3 px-4 w-24">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processes.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="py-8 text-center text-sm text-gray-500"
|
||||
>
|
||||
Sin datos
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processes.map((p) => (
|
||||
<ProcessRow key={p.id} process={p} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
src/components/providers/SelectedClientProvider.tsx
Normal file
69
src/components/providers/SelectedClientProvider.tsx
Normal 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
|
||||
}
|
||||
46
src/components/providers/TrpcProvider.tsx
Normal file
46
src/components/providers/TrpcProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
src/components/reportes/DateRangeFilter.tsx
Normal file
60
src/components/reportes/DateRangeFilter.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
export interface DateRangeValue {
|
||||
desde: string
|
||||
hasta: string
|
||||
}
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
value: DateRangeValue
|
||||
onChange: (value: DateRangeValue) => void
|
||||
}
|
||||
|
||||
export default function DateRangeFilter({ value, onChange }: DateRangeFilterProps) {
|
||||
const handleDesdeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const desde = e.target.value
|
||||
const hasta = value.hasta && desde > value.hasta ? desde : value.hasta
|
||||
onChange({ desde, hasta })
|
||||
}
|
||||
|
||||
const handleHastaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const hasta = e.target.value
|
||||
onChange({ ...value, hasta })
|
||||
}
|
||||
|
||||
const isHastaBeforeDesde =
|
||||
value.desde && value.hasta && value.hasta < value.desde
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
Desde
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={value.desde}
|
||||
onChange={handleDesdeChange}
|
||||
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400">
|
||||
Hasta
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={value.hasta}
|
||||
onChange={handleHastaChange}
|
||||
min={value.desde || undefined}
|
||||
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-200 transition-colors hover:border-white/20 focus:border-cyan-500/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/20"
|
||||
/>
|
||||
</label>
|
||||
{isHastaBeforeDesde && (
|
||||
<p className="text-sm text-amber-400" role="alert">
|
||||
La fecha Hasta no puede ser anterior a Desde
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
src/components/reportes/ReportCard.tsx
Normal file
62
src/components/reportes/ReportCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FileText, FileSpreadsheet } from 'lucide-react'
|
||||
|
||||
interface ReportCardProps {
|
||||
title: string
|
||||
description: string
|
||||
onExportPdf: () => void
|
||||
onExportExcel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function ReportCard({
|
||||
title,
|
||||
description,
|
||||
onExportPdf,
|
||||
onExportExcel,
|
||||
loading = false,
|
||||
}: ReportCardProps) {
|
||||
const btnBase =
|
||||
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50'
|
||||
const btnPrimary =
|
||||
'bg-cyan-600 text-white hover:bg-cyan-500'
|
||||
const btnOutlined =
|
||||
'border border-white/10 text-gray-300 hover:bg-white/5 hover:text-gray-200'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 overflow-hidden',
|
||||
'bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'flex flex-col'
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-white/10 px-4 py-4">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<p className="mt-2 text-sm text-gray-400">{description}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportPdf}
|
||||
disabled={loading}
|
||||
className={cn(btnBase, btnPrimary)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Exportar PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportExcel}
|
||||
disabled={loading}
|
||||
className={cn(btnBase, btnOutlined)}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
src/components/sessions/SessionCard.tsx
Normal file
95
src/components/sessions/SessionCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { Monitor, Terminal, FolderOpen } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SessionTypeLabel = 'Escritorio Remoto' | 'Terminal' | 'Archivos'
|
||||
|
||||
export interface SessionCardProps {
|
||||
id: string
|
||||
deviceName: string
|
||||
userEmail: string
|
||||
sessionType: SessionTypeLabel
|
||||
duration: string
|
||||
onEnd?: (id: string) => void
|
||||
isEnding?: boolean
|
||||
}
|
||||
|
||||
const typeConfig: Record<
|
||||
SessionTypeLabel,
|
||||
{ icon: React.ReactNode; bgClass: string }
|
||||
> = {
|
||||
'Escritorio Remoto': {
|
||||
icon: <Monitor className="h-5 w-5" />,
|
||||
bgClass: 'bg-cyan-500/15 text-cyan-400',
|
||||
},
|
||||
Terminal: {
|
||||
icon: <Terminal className="h-5 w-5" />,
|
||||
bgClass: 'bg-emerald-500/15 text-emerald-400',
|
||||
},
|
||||
Archivos: {
|
||||
icon: <FolderOpen className="h-5 w-5" />,
|
||||
bgClass: 'bg-amber-500/15 text-amber-400',
|
||||
},
|
||||
}
|
||||
|
||||
export default function SessionCard({
|
||||
id,
|
||||
deviceName,
|
||||
userEmail,
|
||||
sessionType,
|
||||
duration,
|
||||
onEnd,
|
||||
isEnding,
|
||||
}: SessionCardProps) {
|
||||
const config = typeConfig[sessionType]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-4 rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5 transition-all duration-200',
|
||||
'sm:flex-row sm:items-center sm:justify-between',
|
||||
'hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
|
||||
config.bgClass
|
||||
)}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-lg font-semibold text-gray-100 truncate">
|
||||
{deviceName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 truncate">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<span className="font-mono text-sm font-medium text-cyan-400 tabular-nums">
|
||||
{duration}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{sessionType}</span>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 sm:pl-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEnd?.(id)}
|
||||
disabled={isEnding}
|
||||
className={cn(
|
||||
'rounded-lg border border-red-500/60 px-4 py-2 text-sm font-medium text-red-400',
|
||||
'transition-colors hover:bg-red-500/15 hover:border-red-500/80',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isEnding ? 'Terminando…' : 'Terminar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
src/components/software/SoftwareRow.tsx
Normal file
48
src/components/software/SoftwareRow.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SoftwareItem {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
vendor: string
|
||||
installations: number
|
||||
lastUpdate: string
|
||||
licensed?: boolean
|
||||
needsUpdate?: boolean
|
||||
}
|
||||
|
||||
interface SoftwareRowProps {
|
||||
item: SoftwareItem
|
||||
}
|
||||
|
||||
export default function SoftwareRow({ item }: SoftwareRowProps) {
|
||||
return (
|
||||
<tr className="border-b border-white/5 last:border-b-0 transition-colors hover:bg-white/5">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-200">{item.name}</span>
|
||||
{item.licensed && (
|
||||
<span className="rounded bg-cyan-500/20 px-1.5 py-0.5 text-xs text-cyan-400">
|
||||
Licencia
|
||||
</span>
|
||||
)}
|
||||
{item.needsUpdate && (
|
||||
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-400">
|
||||
Actualizar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-sm text-gray-400">
|
||||
{item.version}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-400">{item.vendor}</td>
|
||||
<td className="py-3 px-4 font-mono text-sm text-cyan-400 tabular-nums">
|
||||
{item.installations}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">{item.lastUpdate}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
42
src/components/software/SoftwareTable.tsx
Normal file
42
src/components/software/SoftwareTable.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import type { SoftwareItem } from './SoftwareRow'
|
||||
import SoftwareRow from './SoftwareRow'
|
||||
|
||||
interface SoftwareTableProps {
|
||||
items: SoftwareItem[]
|
||||
}
|
||||
|
||||
export default function SoftwareTable({ items }: SoftwareTableProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-xs font-medium uppercase tracking-wide text-gray-400">
|
||||
<th className="py-3 px-4">Nombre</th>
|
||||
<th className="py-3 px-4 w-40">Versión</th>
|
||||
<th className="py-3 px-4 w-48">Editor</th>
|
||||
<th className="py-3 px-4 w-28">Instalaciones</th>
|
||||
<th className="py-3 px-4 w-36">Última actualización</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="py-12 text-center text-sm text-gray-500"
|
||||
>
|
||||
No hay resultados
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<SoftwareRow key={item.id} item={item} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/components/software/SummaryMetricCard.tsx
Normal file
31
src/components/software/SummaryMetricCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SummaryMetricCardProps {
|
||||
value: number
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SummaryMetricCard({
|
||||
value,
|
||||
label,
|
||||
className,
|
||||
}: SummaryMetricCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'flex flex-col items-center justify-center py-6 px-4',
|
||||
'transition-all duration-200 hover:border-white/20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="text-4xl font-bold text-cyan-400 tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-2 text-sm text-gray-400">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/terminal/QuickCommands.tsx
Normal file
36
src/components/terminal/QuickCommands.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
export interface QuickCommandsProps {
|
||||
onSelectCommand: (command: string) => void
|
||||
}
|
||||
|
||||
const COMMANDS = [
|
||||
'systeminfo',
|
||||
'ipconfig',
|
||||
'tasklist',
|
||||
'netstat',
|
||||
'CPU Info',
|
||||
'RAM Info',
|
||||
'dir C:\\',
|
||||
'hostname',
|
||||
]
|
||||
|
||||
export default function QuickCommands({ onSelectCommand }: QuickCommandsProps) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">Comandos Rápidos</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
|
||||
{COMMANDS.map((cmd) => (
|
||||
<button
|
||||
key={cmd}
|
||||
type="button"
|
||||
onClick={() => onSelectCommand(cmd)}
|
||||
className="shrink-0 rounded-lg border border-white/10 bg-dark-300/80 px-4 py-2 font-mono text-xs text-gray-300 transition-colors hover:border-cyan-500/30 hover:bg-dark-200 hover:text-cyan-400"
|
||||
>
|
||||
{cmd}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
153
src/components/terminal/TerminalWindow.tsx
Normal file
153
src/components/terminal/TerminalWindow.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { Send, Copy } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TerminalWindowProps {
|
||||
connectedDeviceName: string | null
|
||||
outputLines: string[]
|
||||
command: string
|
||||
onCommandChange: (value: string) => void
|
||||
onSendCommand: () => void
|
||||
onClear: () => void
|
||||
onCopy: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ASCII_PLACEHOLDER = `
|
||||
__ __ _ ____ _ _
|
||||
| \\/ | ___ ___| |__ / ___|___ _ __ | |_ _ __ __ _| |
|
||||
| |\\/| |/ _ \\/ __| '_ \\| | / _ \\ '_ \\| __| '__/ _\` | |
|
||||
| | | | __/\\__ \\ | | | |__| __/ | | | |_| | | (_| | |
|
||||
|_| |_|\\___||___/_| |_|\\____\\___|_| |_|\\__|_| \\__,_|_|
|
||||
`
|
||||
|
||||
export default function TerminalWindow({
|
||||
connectedDeviceName,
|
||||
outputLines,
|
||||
command,
|
||||
onCommandChange,
|
||||
onSendCommand,
|
||||
onClear,
|
||||
onCopy,
|
||||
disabled = false,
|
||||
}: TerminalWindowProps) {
|
||||
const outputEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
outputEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [outputLines])
|
||||
|
||||
const isEmpty = outputLines.length === 0
|
||||
const showPlaceholder = !connectedDeviceName
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onSendCommand()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[520px] min-h-[320px] flex-col overflow-hidden rounded-xl',
|
||||
'border border-white/10 bg-gradient-to-b from-dark-300/90 to-dark-400/90',
|
||||
'shadow-lg'
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-white/10 bg-dark-200/80 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-3 w-3 rounded-full bg-red-500/90" />
|
||||
<span className="h-3 w-3 rounded-full bg-amber-500/90" />
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500/90" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-400">
|
||||
bash — {connectedDeviceName ?? 'No conectado'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/5 hover:text-gray-200"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 font-mono text-sm">
|
||||
{showPlaceholder ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<pre className="whitespace-pre text-cyan-400/80">{ASCII_PLACEHOLDER}</pre>
|
||||
<p className="mt-6 text-gray-500">
|
||||
Selecciona un dispositivo para iniciar una sesión de terminal.
|
||||
</p>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="flex h-full flex-col justify-end">
|
||||
<div ref={outputEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{outputLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
line.startsWith('$ ')
|
||||
? 'text-cyan-400'
|
||||
: line.startsWith('> ')
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-300'
|
||||
)}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={outputEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-white/10 bg-dark-200/60 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 font-mono text-cyan-400">$</span>
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(e) => onCommandChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe un comando..."
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 bg-transparent font-mono text-gray-200 placeholder-gray-500',
|
||||
'focus:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSendCommand}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-lg bg-cyan-600 px-4 py-2',
|
||||
'text-white transition-all hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
src/lib/trpc-client.ts
Normal file
4
src/lib/trpc-client.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createTRPCReact } from '@trpc/react-query'
|
||||
import type { AppRouter } from '@/server/trpc/routers'
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>()
|
||||
@@ -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':
|
||||
|
||||
83
src/mocks/dashboardData.ts
Normal file
83
src/mocks/dashboardData.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export interface DashboardStats {
|
||||
totalDispositivos: number
|
||||
dispositivosOnline: number
|
||||
dispositivosOffline: number
|
||||
dispositivosAlerta: number
|
||||
secondary?: {
|
||||
total?: string
|
||||
online?: string
|
||||
offline?: string
|
||||
alerta?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceStatusBreakdown {
|
||||
online: number
|
||||
offline: number
|
||||
advertencia: number
|
||||
}
|
||||
|
||||
export type RecentActivityType = 'login' | 'alert' | 'connection' | 'disconnection'
|
||||
|
||||
export type AlertSeverity = 'INFO' | 'WARNING' | 'CRITICAL'
|
||||
|
||||
export interface RecentActivityItem {
|
||||
id: string
|
||||
type: RecentActivityType
|
||||
description: string
|
||||
deviceName: string
|
||||
timestamp: Date
|
||||
severity?: AlertSeverity
|
||||
}
|
||||
|
||||
export interface SystemHealthMetric {
|
||||
label: string
|
||||
value: number
|
||||
unit: string
|
||||
status: 'healthy' | 'warning' | 'critical'
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
cpu: SystemHealthMetric
|
||||
ram: SystemHealthMetric
|
||||
network: SystemHealthMetric
|
||||
}
|
||||
|
||||
export type QuickConnectionStatus = 'online' | 'advertencia' | 'offline'
|
||||
|
||||
export interface QuickConnectionItem {
|
||||
id: string
|
||||
name: string
|
||||
status: QuickConnectionStatus
|
||||
}
|
||||
|
||||
export const MOCK_DASHBOARD_SECONDARY = {
|
||||
total: '+2 este mes',
|
||||
online: '60% disponibilidad',
|
||||
offline: '-1 vs ayer',
|
||||
alerta: '2 requieren atención',
|
||||
}
|
||||
|
||||
export const MOCK_RECENT_ACTIVITY: RecentActivityItem[] = [
|
||||
{ id: '1', type: 'login', description: 'Sesión iniciada', deviceName: 'PC-ADMIN-01', timestamp: new Date(Date.now() - 1000 * 60 * 2) },
|
||||
{ id: '2', type: 'alert', description: 'CPU alta detectada', deviceName: 'LAPTOP-VENTAS', timestamp: new Date(Date.now() - 1000 * 60 * 15) },
|
||||
{ id: '3', type: 'connection', description: 'Dispositivo conectado', deviceName: 'SRV-PRINCIPAL', timestamp: new Date(Date.now() - 1000 * 60 * 32) },
|
||||
{ id: '4', type: 'disconnection', description: 'Conexión perdida', deviceName: 'SRV-BACKUP', timestamp: new Date(Date.now() - 1000 * 60 * 120) },
|
||||
{ id: '5', type: 'alert', description: 'Actualización pendiente', deviceName: 'PC-OFICINA-02', timestamp: new Date(Date.now() - 1000 * 60 * 45) },
|
||||
{ id: '6', type: 'connection', description: 'Dispositivo conectado', deviceName: 'ROUTER-PRINCIPAL', timestamp: new Date(Date.now() - 1000 * 60 * 90) },
|
||||
]
|
||||
|
||||
export const MOCK_SYSTEM_HEALTH: SystemHealth = {
|
||||
cpu: { label: 'CPU Promedio', value: 42, unit: '%', status: 'healthy' },
|
||||
ram: { label: 'RAM Promedio', value: 68, unit: '%', status: 'warning' },
|
||||
network: { label: 'Red', value: 125, unit: 'MB/s', status: 'healthy' },
|
||||
}
|
||||
|
||||
export const MOCK_QUICK_CONNECTIONS: QuickConnectionItem[] = [
|
||||
{ id: '1', name: 'SRV-PRINCIPAL', status: 'online' },
|
||||
{ id: '2', name: 'PC-ADMIN-01', status: 'online' },
|
||||
{ id: '3', name: 'LAPTOP-VENTAS', status: 'advertencia' },
|
||||
{ id: '4', name: 'ROUTER-PRINCIPAL', status: 'online' },
|
||||
{ id: '5', name: 'SW-CORE-01', status: 'online' },
|
||||
{ id: '6', name: 'SRV-BACKUP', status: 'offline' },
|
||||
]
|
||||
103
src/mocks/deviceDetailData.ts
Normal file
103
src/mocks/deviceDetailData.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
export interface DeviceSystemInfo {
|
||||
sistemaOperativo: string
|
||||
procesador: string
|
||||
memoriaRam: string
|
||||
almacenamiento: string
|
||||
almacenamientoUsoPercent?: number
|
||||
}
|
||||
|
||||
export interface DeviceNetworkInfo {
|
||||
direccionIp: string
|
||||
direccionMac: string
|
||||
gateway: string
|
||||
dns: string
|
||||
}
|
||||
|
||||
export interface InstalledSoftware {
|
||||
id: string
|
||||
nombre: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface DeviceDetail {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
systemInfo: DeviceSystemInfo
|
||||
networkInfo: DeviceNetworkInfo
|
||||
software: InstalledSoftware[]
|
||||
}
|
||||
|
||||
export function mapDeviceToDetail(device: {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
sistemaOperativo?: string | null
|
||||
versionSO?: string | null
|
||||
cpu?: string | null
|
||||
ram?: number | null
|
||||
disco?: number | null
|
||||
discoUsage?: number | null
|
||||
ip?: string | null
|
||||
mac?: string | null
|
||||
software?: { id: string; nombre: string; version: string | null }[]
|
||||
}): DeviceDetail {
|
||||
const ramStr = device.ram != null ? `${device.ram} MB` : '—'
|
||||
const discoStr = device.disco != null ? `${device.disco} GB` : '—'
|
||||
return {
|
||||
id: device.id,
|
||||
nombre: device.nombre,
|
||||
tipo: device.tipo,
|
||||
systemInfo: {
|
||||
sistemaOperativo: device.sistemaOperativo?.trim() || '—',
|
||||
procesador: device.cpu?.trim() || '—',
|
||||
memoriaRam: ramStr,
|
||||
almacenamiento: discoStr,
|
||||
almacenamientoUsoPercent: device.discoUsage ?? undefined,
|
||||
},
|
||||
networkInfo: {
|
||||
direccionIp: device.ip?.trim() || '—',
|
||||
direccionMac: device.mac?.trim() || '—',
|
||||
gateway: '—',
|
||||
dns: '—',
|
||||
},
|
||||
software: (device.software ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
nombre: s.nombre,
|
||||
version: s.version ?? '—',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchDeviceDetailMock(deviceId: string): Promise<DeviceDetail> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
id: deviceId,
|
||||
nombre: 'PC-RECEPCION-01',
|
||||
tipo: 'PC',
|
||||
systemInfo: {
|
||||
sistemaOperativo: 'Windows 11 Pro',
|
||||
procesador: 'Intel Core i5-12400',
|
||||
memoriaRam: '16384 MB',
|
||||
almacenamiento: '512 GB',
|
||||
almacenamientoUsoPercent: 62,
|
||||
},
|
||||
networkInfo: {
|
||||
direccionIp: '192.168.1.101',
|
||||
direccionMac: '00:1A:2B:3C:4D:5E',
|
||||
gateway: '192.168.1.1',
|
||||
dns: '8.8.8.8',
|
||||
},
|
||||
software: [
|
||||
{ id: '1', nombre: 'Microsoft Office 365', version: '16.0.17029' },
|
||||
{ id: '2', nombre: 'Google Chrome', version: '120.0.6099' },
|
||||
{ id: '3', nombre: 'Adobe Acrobat Reader', version: '23.006' },
|
||||
{ id: '4', nombre: 'WinRAR', version: '6.24' },
|
||||
],
|
||||
}),
|
||||
400
|
||||
)
|
||||
})
|
||||
}
|
||||
58
src/mocks/mdmDashboardData.ts
Normal file
58
src/mocks/mdmDashboardData.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type DeviceStatus = 'online' | 'offline' | 'kiosk'
|
||||
|
||||
export interface Device {
|
||||
id: string
|
||||
name: string
|
||||
androidVersion: string
|
||||
batteryPercent: number
|
||||
status: DeviceStatus
|
||||
}
|
||||
|
||||
export interface AppDeployment {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
deployed: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalAndroidDevices: number
|
||||
deployedApps: number
|
||||
activePolicies: number
|
||||
averageBatteryPercent: number
|
||||
}
|
||||
|
||||
export const MOCK_DASHBOARD_STATS: DashboardStats = {
|
||||
totalAndroidDevices: 12,
|
||||
deployedApps: 8,
|
||||
activePolicies: 5,
|
||||
averageBatteryPercent: 78,
|
||||
}
|
||||
|
||||
export const MOCK_DEVICES: Device[] = [
|
||||
{ id: '1', name: 'Samsung Galaxy A54 – Ventas01', androidVersion: '14', batteryPercent: 92, status: 'online' },
|
||||
{ id: '2', name: 'Samsung Galaxy A54 – Ventas02', androidVersion: '14', batteryPercent: 45, status: 'online' },
|
||||
{ id: '3', name: 'Xiaomi Redmi Note 12 – Almacén', androidVersion: '13', batteryPercent: 100, status: 'kiosk' },
|
||||
{ id: '4', name: 'Motorola Moto G – Reparto01', androidVersion: '13', batteryPercent: 12, status: 'offline' },
|
||||
{ id: '5', name: 'Samsung Galaxy A34 – Oficina', androidVersion: '14', batteryPercent: 78, status: 'online' },
|
||||
]
|
||||
|
||||
export const MOCK_APP_DEPLOYMENTS: AppDeployment[] = [
|
||||
{ id: '1', name: 'App Corporativa Ventas', version: '2.1.0', deployed: 12, total: 12 },
|
||||
{ id: '2', name: 'Headwind Kiosk', version: '1.4.2', deployed: 10, total: 12 },
|
||||
{ id: '3', name: 'Authenticator', version: '6.2.1', deployed: 12, total: 12 },
|
||||
{ id: '4', name: 'Microsoft Teams', version: '1416/1.0.0', deployed: 8, total: 12 },
|
||||
]
|
||||
|
||||
export function getMdmDashboardData(): {
|
||||
stats: DashboardStats
|
||||
devices: Device[]
|
||||
appDeployments: AppDeployment[]
|
||||
} {
|
||||
return {
|
||||
stats: MOCK_DASHBOARD_STATS,
|
||||
devices: MOCK_DEVICES,
|
||||
appDeployments: MOCK_APP_DEPLOYMENTS,
|
||||
}
|
||||
}
|
||||
100
src/mocks/reportService.ts
Normal file
100
src/mocks/reportService.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export interface ReportFilters {
|
||||
desde: string
|
||||
hasta: string
|
||||
}
|
||||
|
||||
export interface InventoryReportItem {
|
||||
id: string
|
||||
nombre: string
|
||||
tipo: string
|
||||
cliente: string
|
||||
ip: string
|
||||
so: string
|
||||
estado: string
|
||||
}
|
||||
|
||||
export interface InventoryReport {
|
||||
periodo: { desde: string; hasta: string }
|
||||
total: number
|
||||
items: InventoryReportItem[]
|
||||
}
|
||||
|
||||
export interface ResourceUsageReportDevice {
|
||||
dispositivo: string
|
||||
cliente: string
|
||||
cpuPromedio: number
|
||||
memoriaPromedio: number
|
||||
redPromedioMB: number
|
||||
}
|
||||
|
||||
export interface ResourceUsageReport {
|
||||
periodo: { desde: string; hasta: string }
|
||||
dispositivos: ResourceUsageReportDevice[]
|
||||
}
|
||||
|
||||
export interface AlertsReportItem {
|
||||
id: string
|
||||
fecha: string
|
||||
titulo: string
|
||||
severidad: string
|
||||
estado: string
|
||||
dispositivo: string
|
||||
resueltoEn: string | null
|
||||
}
|
||||
|
||||
export interface AlertsReport {
|
||||
periodo: { desde: string; hasta: string }
|
||||
total: number
|
||||
items: AlertsReportItem[]
|
||||
}
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
return d.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// TODO: replace with trpc.reportes.inventario (and optional date filter if backend supports it)
|
||||
export async function fetchInventoryReport(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<InventoryReport> {
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
return {
|
||||
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
|
||||
total: 42,
|
||||
items: [
|
||||
{ id: '1', nombre: 'PC-Oficina-01', tipo: 'PC', cliente: 'Cliente A', ip: '192.168.1.10', so: 'Windows 11', estado: 'Activo' },
|
||||
{ id: '2', nombre: 'LAPTOP-Ventas-02', tipo: 'LAPTOP', cliente: 'Cliente A', ip: '192.168.1.22', so: 'Windows 11', estado: 'Activo' },
|
||||
{ id: '3', nombre: 'SRV-DC-01', tipo: 'SERVIDOR', cliente: 'Cliente B', ip: '10.0.0.5', so: 'Windows Server 2022', estado: 'Activo' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchResourceUsageReport(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<ResourceUsageReport> {
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
return {
|
||||
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
|
||||
dispositivos: [
|
||||
{ dispositivo: 'PC-Oficina-01', cliente: 'Cliente A', cpuPromedio: 24, memoriaPromedio: 62, redPromedioMB: 120 },
|
||||
{ dispositivo: 'LAPTOP-Ventas-02', cliente: 'Cliente A', cpuPromedio: 18, memoriaPromedio: 45, redPromedioMB: 85 },
|
||||
{ dispositivo: 'SRV-DC-01', cliente: 'Cliente B', cpuPromedio: 12, memoriaPromedio: 78, redPromedioMB: 340 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAlertsReport(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AlertsReport> {
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
return {
|
||||
periodo: { desde: toDateStr(startDate), hasta: toDateStr(endDate) },
|
||||
total: 28,
|
||||
items: [
|
||||
{ id: '1', fecha: '2024-01-15T10:30:00Z', titulo: 'CPU alto', severidad: 'CRITICAL', estado: 'RESUELTA', dispositivo: 'PC-Oficina-01', resueltoEn: '2024-01-15T10:45:00Z' },
|
||||
{ id: '2', fecha: '2024-01-15T09:00:00Z', titulo: 'Disco lleno', severidad: 'WARNING', estado: 'RESUELTA', dispositivo: 'SRV-DC-01', resueltoEn: '2024-01-15T11:00:00Z' },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
|
||||
|
||||
47
src/server/trpc/routers/sesiones.router.ts
Normal file
47
src/server/trpc/routers/sesiones.router.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod'
|
||||
import { router, protectedProcedure } from '../trpc'
|
||||
|
||||
export const sesionesRouter = router({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
clienteId: z.string().optional(),
|
||||
limit: z.number().default(50),
|
||||
}).optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { clienteId, limit = 50 } = input || {}
|
||||
|
||||
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
|
||||
const where = {
|
||||
finalizadaEn: null,
|
||||
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
|
||||
}
|
||||
|
||||
const sessions = await ctx.prisma.sesionRemota.findMany({
|
||||
where,
|
||||
include: {
|
||||
usuario: { select: { id: true, email: true, nombre: true } },
|
||||
dispositivo: { select: { id: true, nombre: true, clienteId: true } },
|
||||
},
|
||||
orderBy: { iniciadaEn: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
|
||||
return { sessions }
|
||||
}),
|
||||
|
||||
count: protectedProcedure
|
||||
.input(z.object({ clienteId: z.string().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const clienteId = ctx.user.clienteId || input?.clienteId
|
||||
|
||||
const clientFilter = clienteId ?? ctx.user.clienteId ?? undefined
|
||||
const where = {
|
||||
finalizadaEn: null,
|
||||
...(clientFilter ? { dispositivo: { clienteId: clientFilter } } : {}),
|
||||
}
|
||||
|
||||
return ctx.prisma.sesionRemota.count({ where })
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user