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