Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lab-03-async-realtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ import asyncio, websockets, json

async def test():
async with websockets.connect(
'ws://localhost:8000/ws/tracking/bus-001/',
'ws://localhost:8001/ws/tracking/bus-001/',
additional_headers={'Origin': 'http://localhost'}
) as ws:
print('Conectado:', json.loads(await ws.recv()))
Expand Down
4 changes: 2 additions & 2 deletions lab-03-async-realtime/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ services:
# ── Django / Daphne (ASGI) ────────────────────────────────────────────────
web:
build: .
command: daphne -b 0.0.0.0 -p 8000 config.asgi:application
command: daphne -b 0.0.0.0 -p 8001 config.asgi:application
ports:
- "8000:8000"
- "8001:8001"
env_file: .env
volumes:
- .:/app
Expand Down
7 changes: 7 additions & 0 deletions lab-06-frontend-nuxt/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
.nuxt/
.output/
dist/
.env
*.log
pnpm-lock.yaml
14 changes: 14 additions & 0 deletions lab-06-frontend-nuxt/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM node:20-slim

RUN npm install -g pnpm

WORKDIR /app

COPY package.json pnpm-lock.yaml* ./
RUN pnpm install

COPY . .

EXPOSE 3000

CMD ["pnpm", "dev", "--host"]
150 changes: 150 additions & 0 deletions lab-06-frontend-nuxt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Lab 06 — Frontend: Nuxt 3 · Vue 3 · TypeScript · Nuxt UI

Dashboard de transporte público construido con Nuxt 3. Consume la API REST del Lab 02 (rutas y paradas) y posiciones de flota en tiempo real vía WebSocket del Lab 03 (`/ws/fleet/`).

---

## Stack

| Componente | Tecnología |
|---|---|
| **Framework** | Nuxt 3 |
| **UI** | Vue 3 · Nuxt UI |
| **Lenguaje** | TypeScript |
| **Mapa** | Leaflet · @nuxtjs/leaflet |
| **Gestor de paquetes** | pnpm |
| **Infraestructura** | Docker · Docker Compose · Node 20 |

---

## Conceptos Demostrados

### Nuxt 3 como BFF (Backend for Frontend)
Las server routes de Nuxt (`server/api/`) actúan como proxy entre el browser y los backends. Esto centraliza la gestión de headers, autenticación y transformación de respuestas sin exponer las URLs internas al cliente.

### Composables tipados
`useApi.ts` encapsula `$fetch` con tipos genéricos para todas las llamadas al backend DRF. `useWebSocket.ts` gestiona el ciclo de vida de la conexión WebSocket (apertura, reconexión, limpieza) como un composable reutilizable.

### Renderizado híbrido
Las páginas de listado usan `useFetch` con SSR para SEO y carga inicial rápida. Las vistas en tiempo real usan `onMounted` para conectar el WebSocket solo en el cliente.

---

## Estructura

```
lab-06-frontend-nuxt/
├── docker-compose.yml
├── Dockerfile
├── nuxt.config.ts
├── package.json
├── tsconfig.json
├── app.vue
├── pages/
│ ├── index.vue # Dashboard principal con mapa
│ ├── routes/
│ │ ├── index.vue # Lista de rutas (API DRF)
│ │ └── [id].vue # Detalle de ruta con mapa Leaflet
│ └── live.vue # Feed en tiempo real (WebSocket)
├── components/
│ ├── RouteCard.vue # Tarjeta de ruta reutilizable
│ ├── MapView.vue # Mapa Leaflet con paradas
│ └── LiveFeed.vue # Lista de alertas en tiempo real
├── composables/
│ ├── useApi.ts # Fetch wrapper tipado hacia el backend
│ └── useWebSocket.ts # Gestión de conexión WebSocket
└── server/
└── api/
├── routes.ts # Proxy → Lab 02 DRF /api/routes/
└── routes/[id].ts # Proxy → Lab 02 DRF /api/routes/:id/
```

---

## Servicios Docker

| Servicio | Puerto (host) | Descripción |
|---|---|---|
| `web` | 3000 | Nuxt 3 (SSR + dev server) |
| Lab 02 `app` | 8000 | Django REST API (rutas, paradas) |
| Lab 03 `web` | 8001 | Django Channels / Daphne (WebSocket flota `ws://localhost:8001/ws/fleet/`) |

---

## Inicio Rápido

### 1. Levantar el Lab 02 (API de rutas)

```bash
cd ../lab-02-django-api
docker compose up -d
docker compose exec app python manage.py migrate
```

Crear datos de prueba:

```bash
docker compose exec app python manage.py shell -c "
from apps.routes.models import Route
Route.objects.create(code='R01', name='Ruta Escazu', origin='San Jose', destination='Escazu')
Route.objects.create(code='R02', name='Ruta Cartago', origin='San Jose', destination='Cartago')
Route.objects.create(code='R03', name='Ruta Alajuela', origin='San Jose', destination='Alajuela')
"
```

> **Nota:** PowerShell puede corromper caracteres acentuados en comandos inline. Si los nombres aparecen mal, use el Django Admin en `http://localhost:8000/admin/` para crear los datos.

### 2. Levantar el Lab 03 (WebSocket de flota)

```bash
cd ../lab-03-async-realtime
docker compose up -d
docker compose exec web python manage.py migrate
```

Simular posiciones de vehículos (con el browser en `/live` abierto):

```bash
docker compose exec web python manage.py shell -c "from apps.tasks.workers import ingest_vehicle_position; ingest_vehicle_position('BUS-001', 9.9337, -84.0800, 45.2, 180.0, '2026-03-21T12:00:00Z'); ingest_vehicle_position('BUS-002', 9.9381, -84.0505, 38.7, 90.0, '2026-03-21T12:00:00Z')"
```

> **Importante:** abra la página `/live` en el browser **antes** de correr el comando anterior. El WebSocket solo recibe los mensajes publicados mientras la conexión está activa.

### 3. Levantar el Lab 06

```bash
cd ../lab-06-frontend-nuxt
docker compose up -d

# Ver logs del servidor Nuxt
docker compose logs -f web

# Acceder al dashboard
# http://localhost:3000
```

---

## Páginas

### `/` — Dashboard
Vista principal con mapa centrado en San José, CR. Muestra un resumen de rutas activas y el estado del sistema.

### `/routes` — Lista de Rutas
Tabla con filtro por nombre y origen/destino. Consume el endpoint `GET /api/routes/` del Lab 02 a través del BFF de Nuxt.

### `/routes/[id]` — Detalle de Ruta
Mapa Leaflet con los marcadores de cada parada de la ruta. Muestra nombre, coordenadas y horarios asociados.

### `/live` — Flota en Tiempo Real
Tabla de posiciones de vehículos conectada por WebSocket al endpoint `ws://localhost:8001/ws/fleet/` del Lab 03. Cada vez que un vehículo publica una posición, la fila se actualiza en tiempo real sin recargar la página. Muestra vehículo, latitud, longitud, velocidad y hora de última actualización.

---

## Qué Demuestra Este Laboratorio

- **Nuxt 3 con TypeScript** end-to-end: páginas, componentes, composables y server routes tipados
- **Patrón BFF** con server routes de Nuxt como capa de proxy hacia múltiples backends
- **Composables reutilizables** para abstracción de HTTP y WebSocket
- **Leaflet integrado en Vue 3** con renderizado SSR-safe (carga dinámica en el cliente)
- **Consumo de API REST y WebSocket** desde el mismo frontend, conectando los Labs 02 y 03
7 changes: 7 additions & 0 deletions lab-06-frontend-nuxt/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<template>
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>
28 changes: 28 additions & 0 deletions lab-06-frontend-nuxt/components/LiveFeed.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<template>
<div class="space-y-2">
<div v-if="alerts.length === 0" class="text-gray-400 text-sm text-center py-4">
Sin alertas recientes.
</div>
<div
v-for="alert in alerts"
:key="alert.id"
class="flex items-start gap-2 text-sm"
>
<UBadge
:color="alert.severity === 'critical' ? 'red' : alert.severity === 'warning' ? 'yellow' : 'blue'"
variant="soft"
size="xs"
class="shrink-0 mt-0.5"
>
{{ alert.severity }}
</UBadge>
<span class="text-gray-700">{{ alert.message }}</span>
</div>
</div>
</template>

<script setup lang="ts">
import type { Alert } from '~/composables/useWebSocket'

defineProps<{ alerts: Alert[] }>()
</script>
38 changes: 38 additions & 0 deletions lab-06-frontend-nuxt/components/MapView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<div ref="mapContainer" class="h-64 w-full rounded-lg z-0" />
</template>

<script setup lang="ts">
import type { Stop } from '~/composables/useApi'

const props = defineProps<{ stops: Stop[] }>()
const mapContainer = ref<HTMLElement | null>(null)

onMounted(async () => {
if (!mapContainer.value || props.stops.length === 0) return

const L = (await import('leaflet')).default
await import('leaflet/dist/leaflet.css')

// Fix default icon paths broken by bundlers
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})

const center = props.stops[0]
const map = L.map(mapContainer.value).setView([center.lat, center.lon], 13)

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
}).addTo(map)

for (const stop of props.stops) {
L.marker([stop.lat, stop.lon])
.addTo(map)
.bindPopup(`<strong>${stop.name}</strong>`)
}
})
</script>
19 changes: 19 additions & 0 deletions lab-06-frontend-nuxt/components/RouteCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<template>
<NuxtLink :to="`/routes/${route.id}`" class="block">
<div class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 border border-gray-100 transition-colors">
<div>
<p class="font-medium text-sm">{{ route.name }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ route.origin }} → {{ route.destination }}</p>
</div>
<UBadge :color="route.is_active ? 'green' : 'gray'" variant="soft" size="xs">
{{ route.is_active ? 'Activa' : 'Inactiva' }}
</UBadge>
</div>
</NuxtLink>
</template>

<script setup lang="ts">
import type { Route } from '~/composables/useApi'

defineProps<{ route: Route }>()
</script>
28 changes: 28 additions & 0 deletions lab-06-frontend-nuxt/composables/useApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface Route {
id: number
code: string
name: string
origin: string
destination: string
is_active: boolean
}

export interface Stop {
id: number
name: string
lat: number
lon: number
route: number
}

export function useApi() {
async function getRoutes(): Promise<Route[]> {
return $fetch<Route[]>('/api/routes')
}

async function getRoute(id: number | string): Promise<Route & { stops: Stop[] }> {
return $fetch(`/api/routes/${id}`)
}

return { getRoutes, getRoute }
}
53 changes: 53 additions & 0 deletions lab-06-frontend-nuxt/composables/useWebSocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export interface VehiclePosition {
type: string
vehicle_id: string
lat: number
lon: number
speed_kmh: number
timestamp: string
}

export function useWebSocket(url: string) {
const positions = ref<VehiclePosition[]>([])
const connected = ref(false)
let ws: WebSocket | null = null

function connect() {
ws = new WebSocket(url)

ws.onopen = () => {
connected.value = true
}

ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as VehiclePosition
// Keep only the latest position per vehicle
const idx = positions.value.findIndex(p => p.vehicle_id === data.vehicle_id)
if (idx >= 0) {
positions.value[idx] = data
} else {
positions.value.unshift(data)
}
} catch {
// ignore malformed messages
}
}

ws.onclose = () => {
connected.value = false
setTimeout(connect, 3000)
}

ws.onerror = () => {
ws?.close()
}
}

function disconnect() {
ws?.close()
ws = null
}

return { positions, connected, connect, disconnect }
}
16 changes: 16 additions & 0 deletions lab-06-frontend-nuxt/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
services:
web:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
- /app/.nuxt
environment:
- NUXT_API_BASE=${NUXT_API_BASE:-http://host.docker.internal:8000}
- NUXT_WS_BASE=${NUXT_WS_BASE:-ws://host.docker.internal:8001}
- NUXT_HOST=0.0.0.0
- NUXT_PORT=3000
extra_hosts:
- "host.docker.internal:host-gateway"
Loading
Loading