Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Example environment variables - do NOT commit secrets
|
||||
VITE_COGNITO_USER_POOL_ID=
|
||||
VITE_COGNITO_CLIENT_ID=
|
||||
VITE_API_URL=https://your-api-id.execute-api.<region>.amazonaws.com
|
||||
|
||||
# Optional: if you have a Cognito Hosted UI domain you can add it to enable redirects
|
||||
# VITE_COGNITO_HOSTED_UI_DOMAIN=your-domain.auth.<region>.amazoncognito.com
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# Node
|
||||
node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.cache/
|
||||
|
||||
# Vite/Next/CRA
|
||||
.next/
|
||||
.cache/
|
||||
|
||||
# Static analysis
|
||||
coverage/
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# AWS / Amplify
|
||||
aws-exports.js
|
||||
aws-exports.ts
|
||||
amplify/
|
||||
|
||||
# Credentials and keys
|
||||
*.pem
|
||||
*.key
|
||||
credentials
|
||||
aws-credentials
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Misc
|
||||
*.local
|
||||
@@ -0,0 +1,108 @@
|
||||
iot-front-dashboard
|
||||
====================
|
||||
|
||||
Resumen
|
||||
------
|
||||
Aplicación web sencilla (Vue 3 + Vite + TypeScript) para visualizar las mediciones IoT. Está diseñada como una primera versión didáctica, deliberadamente simple, de modo que los alumnos puedan luego refactorizarla.
|
||||
|
||||
Tecnologías
|
||||
----------
|
||||
- Vue 3
|
||||
- Vite
|
||||
- TypeScript
|
||||
- HTML / CSS
|
||||
- Amazon Cognito (autenticación)
|
||||
|
||||
Estructura
|
||||
---------
|
||||
```
|
||||
iot-front-dashboard/
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── .env.example
|
||||
├── index.html
|
||||
└── src/
|
||||
├── main.ts
|
||||
├── App.vue
|
||||
└── style.css
|
||||
```
|
||||
|
||||
Variables de entorno
|
||||
-------------------
|
||||
Copiar `.env.example` a `.env` y ajustar valores:
|
||||
|
||||
- VITE_COGNITO_USER_POOL_ID: (no obligatorio para esta primera versión)
|
||||
- VITE_COGNITO_CLIENT_ID: (no obligatorio para esta primera version)
|
||||
- VITE_API_URL: URL base de la API (por ejemplo https://<api-id>.execute-api.<region>.amazonaws.com)
|
||||
|
||||
No pongas credenciales reales en el repositorio.
|
||||
|
||||
Login y obtención del JWT
|
||||
-------------------------
|
||||
Esta primera versión implementa un login simple: el usuario debe obtener un JWT (id o access token) desde Cognito y pegarlo en la aplicación. El token se almacena en localStorage como `iot_token`.
|
||||
|
||||
Cómo obtener un token desde Cognito (sugerencias):
|
||||
- Usar el Hosted UI de Cognito (si el App Client está configurado con callback y dominio) y copiar el token desde la URL o desde las herramientas de desarrollador.
|
||||
- Obtener un token con herramientas administrativas o scripts (ej. usar AWS CLI/AdminInitiateAuth si se dispone de permisos).
|
||||
|
||||
Nota: automatizar el flujo de login (Hosted UI redirect / Authorization Code) requiere configurar callback URLs y dominio en el App Client y puede ser añadido en versiones posteriores.
|
||||
|
||||
Ejecución local
|
||||
---------------
|
||||
1. Instalar dependencias:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Crear `.env` a partir de `.env.example` y ajustar VITE_API_URL.
|
||||
|
||||
3. Ejecutar la app en modo desarrollo:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Uso
|
||||
---
|
||||
1. Obtener un JWT de Cognito (ver sección anterior).
|
||||
2. Pegar el token en el textarea y hacer click en "Usar token (Login)".
|
||||
3. La app llamará a `GET ${VITE_API_URL}/readings` enviando el header `Authorization: Bearer <TOKEN>`.
|
||||
4. Verás indicadores sencillos (temperatura/humedad actuales) y una tabla con las lecturas.
|
||||
|
||||
API esperada
|
||||
-----------
|
||||
El endpoint `/readings` debe devolver JSON con la forma:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"deviceId": "ARDUINO-01",
|
||||
"timestamp": "2026-09-02T18:00:00Z",
|
||||
"temperature": 24.7,
|
||||
"humidity": 58.2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Estados iniciales y manejo de errores
|
||||
-----------------------------------
|
||||
- Cargando: "Cargando datos..."
|
||||
- Error simple: "No fue posible obtener los datos"
|
||||
- 401: mostrado como "No autorizado (401)" (API Gateway/Cognito normalmente maneja la validación)
|
||||
|
||||
Refactorización posterior
|
||||
-------------------------
|
||||
Esta versión concentra gran parte de la lógica en `App.vue` intencionalmente. En tareas posteriores se pedirá a los alumnos que separen responsabilidades (componentes, servicios HTTP, manejo de estado) y mejoren la experiencia y seguridad.
|
||||
|
||||
Relación con Cognito y la API
|
||||
----------------------------
|
||||
- Cognito emite el JWT que la app usará como bearer token.
|
||||
- La API (API Gateway + Cognito Authorizer + Lambda) protege `/readings` y delega la autenticación a Cognito.
|
||||
|
||||
Contacto
|
||||
--------
|
||||
Proyecto educativo — implementación simple para fines didácticos.
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>IoT Front Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "iot-front-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vue-tsc": "^1.0.0"
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>IoT Dashboard</h1>
|
||||
|
||||
<div v-if="!token" class="card">
|
||||
<h2>Iniciar sesión</h2>
|
||||
<p>La aplicación usa Amazon Cognito para autenticación. Obtén un JWT (id o access token) desde Cognito y pégalo aquí.</p>
|
||||
|
||||
<textarea v-model="tokenInput" placeholder="Pega tu JWT aquí" rows="6"></textarea>
|
||||
<div class="row">
|
||||
<button @click="doLogin">Usar token (Login)</button>
|
||||
<button @click="clearToken" class="secondary">Limpiar</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Opcional: si tienes configurado Hosted UI, genera el JWT mediante Cognito (ver README).</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="row space-between">
|
||||
<div>
|
||||
<strong>Autenticado</strong>
|
||||
</div>
|
||||
<div>
|
||||
<button @click="logout" class="secondary">Cerrar sesión</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Indicadores</h2>
|
||||
<div v-if="loading">Cargando datos...</div>
|
||||
<div v-else-if="error">No fue posible obtener los datos: {{ error }}</div>
|
||||
<div v-else>
|
||||
<div class="indicators">
|
||||
<div class="indicator">
|
||||
<div class="label">Temperatura actual</div>
|
||||
<div class="value">{{ latest?.temperature ?? '—' }} °C</div>
|
||||
</div>
|
||||
<div class="indicator">
|
||||
<div class="label">Humedad actual</div>
|
||||
<div class="value">{{ latest?.humidity ?? '—' }} %</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Lecturas</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dispositivo</th>
|
||||
<th>Fecha</th>
|
||||
<th>Temperatura (°C)</th>
|
||||
<th>Humedad (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in readings" :key="r.deviceId + r.timestamp">
|
||||
<td>{{ r.deviceId }}</td>
|
||||
<td>{{ r.timestamp }}</td>
|
||||
<td>{{ r.temperature }}</td>
|
||||
<td>{{ r.humidity }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
type Reading = {
|
||||
deviceId: string
|
||||
timestamp: string
|
||||
temperature: number
|
||||
humidity: number
|
||||
}
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const API_URL = (import.meta.env.VITE_API_URL as string) || ''
|
||||
|
||||
const token = ref<string | null>(localStorage.getItem('iot_token'))
|
||||
const tokenInput = ref('')
|
||||
const readings = ref<Reading[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const latest = computed(() => {
|
||||
if (!readings.value.length) return null
|
||||
// try to find the latest by timestamp (ISO strings assumed)
|
||||
return readings.value.slice().sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1))[0]
|
||||
})
|
||||
|
||||
async function fetchReadings() {
|
||||
if (!API_URL) {
|
||||
error.value = 'VITE_API_URL no está configurada. Revisa .env'
|
||||
return
|
||||
}
|
||||
if (!token.value) {
|
||||
error.value = 'No autenticado'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch(`${API_URL.replace(/\/+$/, '')}/readings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.value}`
|
||||
}
|
||||
})
|
||||
if (res.status === 401) {
|
||||
error.value = 'No autorizado (401)'
|
||||
readings.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
error.value = `HTTP ${res.status}`
|
||||
readings.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
readings.value = Array.isArray(data?.data) ? data.data : []
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || String(e)
|
||||
readings.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function doLogin() {
|
||||
if (!tokenInput.value) return
|
||||
token.value = tokenInput.value.trim()
|
||||
localStorage.setItem('iot_token', token.value)
|
||||
// immediately fetch
|
||||
fetchReadings()
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
localStorage.removeItem('iot_token')
|
||||
readings.value = []
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
tokenInput.value = ''
|
||||
}
|
||||
|
||||
// If token already present, fetch on mount
|
||||
onMounted(() => {
|
||||
if (token.value) {
|
||||
fetchReadings()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
token,
|
||||
tokenInput,
|
||||
doLogin,
|
||||
clearToken,
|
||||
logout,
|
||||
readings,
|
||||
latest,
|
||||
loading,
|
||||
error
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 32px auto;
|
||||
padding: 0 16px;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e6e6e6;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.row { display:flex; gap:8px; align-items:center; }
|
||||
.space-between { justify-content:space-between; }
|
||||
textarea { width:100%; font-family: monospace; }
|
||||
button { padding:8px 12px; cursor:pointer; }
|
||||
button.secondary { background:#eee; }
|
||||
.indicators { display:flex; gap:16px; margin-bottom:12px; }
|
||||
.indicator { padding:8px; background:#f7f7f7; border-radius:4px; }
|
||||
.label { font-size:0.9rem; color:#666; }
|
||||
.value { font-size:1.5rem; font-weight:600; }
|
||||
table { width:100%; border-collapse:collapse; margin-top:8px; }
|
||||
th, td { text-align:left; padding:8px; border-bottom:1px solid #eee; }
|
||||
.hint { color:#666; font-size:0.9rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,2 @@
|
||||
html, body, #app { height: 100%; margin: 0; }
|
||||
body { background: #f0f2f5; }
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user