alexp97 commited on
Commit
df71cfc
·
1 Parent(s): 07227e5

feat: add data explorer tab, enforce BYOK in production, detect env

Browse files
backend/api/credentials.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  Archivo: credentials.py
3
- Fecha de modificación: 03/06/2026
4
  Autor: Equipo AgroVisión
5
 
6
  Descripción:
@@ -9,8 +9,12 @@ presentes en el `.env` del servidor** (variables `DEV_*` / `SUPABASE_*` / `DATAB
9
  para que la UI pueda ofrecer "usar las del servidor" sin que el usuario las reescriba.
10
  Devuelve **solo booleanos de presencia** — nunca los valores — para no exponer secretos.
11
 
 
 
 
 
12
  Estructura Interna:
13
- - `GET /api/credentials/status`: presencia (bool) de Groq, Copernicus y Supabase.
14
 
15
  Entradas / Dependencias:
16
  - Variables de entorno (cargadas por `backend.config`).
@@ -25,16 +29,28 @@ import os
25
 
26
  from fastapi import APIRouter
27
 
 
 
28
  router = APIRouter(prefix="/api/credentials", tags=["credenciales"])
29
 
30
 
31
  @router.get("/status")
32
  def credentials_status() -> dict:
33
- """Presencia (bool) de las credenciales en el `.env` del servidor (sin valores)."""
 
 
 
 
 
 
 
 
 
 
 
34
  return {
35
- "groq": bool(os.getenv("DEV_GROQ_API_KEY")),
36
- "copernicus": bool(
37
- os.getenv("DEV_COPERNICUS_CLIENT_ID") and os.getenv("DEV_COPERNICUS_CLIENT_SECRET")
38
- ),
39
  "supabase": bool(os.getenv("DATABASE_URL")),
40
  }
 
1
  """
2
  Archivo: credentials.py
3
+ Fecha de modificación: 04/06/2026
4
  Autor: Equipo AgroVisión
5
 
6
  Descripción:
 
9
  para que la UI pueda ofrecer "usar las del servidor" sin que el usuario las reescriba.
10
  Devuelve **solo booleanos de presencia** — nunca los valores — para no exponer secretos.
11
 
12
+ En **producción** (`APP_ENV=production`), las `DEV_*` keys NO están disponibles
13
+ (cada usuario debe poner las suyas). El endpoint reporta `env: "production"` para
14
+ que la UI muestre el aviso correspondiente.
15
+
16
  Estructura Interna:
17
+ - `GET /api/credentials/status`: presencia (bool) de Groq, Copernicus y Supabase + entorno.
18
 
19
  Entradas / Dependencias:
20
  - Variables de entorno (cargadas por `backend.config`).
 
29
 
30
  from fastapi import APIRouter
31
 
32
+ from backend.config import get_settings
33
+
34
  router = APIRouter(prefix="/api/credentials", tags=["credenciales"])
35
 
36
 
37
  @router.get("/status")
38
  def credentials_status() -> dict:
39
+ """Presencia (bool) de las credenciales en el `.env` del servidor (sin valores) + entorno."""
40
+ settings = get_settings()
41
+ is_dev = settings.app_env == "development"
42
+
43
+ # En producción, las DEV_* keys NO deben usarse (cada usuario pone las suyas)
44
+ dev_groq = bool(os.getenv("DEV_GROQ_API_KEY")) if is_dev else False
45
+ dev_cop = (
46
+ bool(os.getenv("DEV_COPERNICUS_CLIENT_ID") and os.getenv("DEV_COPERNICUS_CLIENT_SECRET"))
47
+ if is_dev
48
+ else False
49
+ )
50
+
51
  return {
52
+ "env": settings.app_env,
53
+ "groq": dev_groq,
54
+ "copernicus": dev_cop,
 
55
  "supabase": bool(os.getenv("DATABASE_URL")),
56
  }
backend/api/data.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Archivo: data.py
3
+ Fecha de modificación: 04/06/2026
4
+ Autor: Equipo AgroVisión
5
+
6
+ Descripción:
7
+ Router del **Explorador de Datos** (Fase 11). Permite consultar las tablas de la BD
8
+ Supabase directamente desde la UI. Expone dos endpoints:
9
+ - `GET /api/data/{table}`: lista los primeros 100 registros de una tabla permitida.
10
+ - `POST /api/data/query`: ejecuta una consulta SQL personalizada (solo SELECT).
11
+
12
+ Seguridad:
13
+ - Solo tablas explícitamente permitidas (`ALLOWED_TABLES`).
14
+ - Solo consultas `SELECT` (bloquea INSERT/UPDATE/DELETE/ALTER/DROP).
15
+ - Límite de 1000 filas por consulta.
16
+ - Timeout de 10 segundos.
17
+
18
+ Entradas / Dependencias:
19
+ - `backend.api.deps.get_db`, `backend.api.deps.get_user_keys`.
20
+
21
+ Ejemplo de Integración:
22
+ from backend.api.data import router
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+
29
+ from fastapi import APIRouter, Depends, HTTPException
30
+ from sqlalchemy import text
31
+ from sqlalchemy.ext.asyncio import AsyncSession
32
+
33
+ from backend.api.deps import get_db
34
+
35
+ router = APIRouter(prefix="/api/data", tags=["explorador de datos"])
36
+
37
+ # Tablas permitidas para consulta directa
38
+ ALLOWED_TABLES: frozenset[str] = frozenset({
39
+ "fields",
40
+ "ndvi_timeseries",
41
+ "chat_messages",
42
+ "events",
43
+ "plant_counts",
44
+ })
45
+
46
+ # Límite máximo de filas por consulta
47
+ MAX_ROWS = 1000
48
+
49
+
50
+ def _validate_query(sql: str) -> str:
51
+ """
52
+ Valida que la consulta sea solo SELECT (lectura).
53
+
54
+ Args:
55
+ sql: Consulta SQL propuesta.
56
+
57
+ Returns:
58
+ La consulta limpia (stripped).
59
+
60
+ Raises:
61
+ HTTPException: Si la consulta no es SELECT o intenta modificar datos.
62
+ """
63
+ sql = sql.strip().rstrip(";")
64
+ upper = sql.upper()
65
+
66
+ # Bloquear cualquier cosa que no sea SELECT
67
+ if not upper.startswith("SELECT"):
68
+ raise HTTPException(status_code=400, detail="Solo se permiten consultas SELECT.")
69
+
70
+ # Bloquear palabras clave peligrosas
71
+ dangerous = [
72
+ "INSERT", "UPDATE", "DELETE", "ALTER", "DROP",
73
+ "CREATE", "TRUNCATE", "GRANT", "REVOKE",
74
+ ]
75
+ for word in dangerous:
76
+ if re.search(r"\b" + word + r"\b", upper):
77
+ raise HTTPException(status_code=400, detail=f"Consulta bloqueada: contiene '{word}'.")
78
+
79
+ return sql
80
+
81
+
82
+ @router.get("/{table}")
83
+ async def get_table(
84
+ table: str,
85
+ session: AsyncSession = Depends(get_db),
86
+ ) -> dict:
87
+ """
88
+ Lista los primeros 100 registros de una tabla permitida.
89
+
90
+ Args:
91
+ table: Nombre de la tabla (debe estar en ALLOWED_TABLES).
92
+ session: Sesión de BD.
93
+
94
+ Returns:
95
+ Dict con `rows` (lista de dicts) y `count`.
96
+
97
+ Raises:
98
+ HTTPException: Si la tabla no está permitida o no existe.
99
+ """
100
+ if table not in ALLOWED_TABLES:
101
+ raise HTTPException(
102
+ status_code=400,
103
+ detail=f"Tabla no permitida. Permitidas: {sorted(ALLOWED_TABLES)}",
104
+ )
105
+
106
+ try:
107
+ query = text(f"SELECT * FROM {table} LIMIT 100")
108
+ result = await session.execute(query)
109
+ rows = [dict(row._mapping) for row in result.fetchall()]
110
+ # Convertir tipos no serializables
111
+ for row in rows:
112
+ for k, v in row.items():
113
+ if hasattr(v, "isoformat"):
114
+ row[k] = v.isoformat()
115
+ elif hasattr(v, "__geo_interface__"):
116
+ row[k] = dict(v.__geo_interface__)
117
+ return {"table": table, "rows": rows, "count": len(rows)}
118
+ except Exception as e:
119
+ raise HTTPException(status_code=500, detail=str(e)) from e
120
+
121
+
122
+ @router.post("/query")
123
+ async def run_query(
124
+ body: dict,
125
+ session: AsyncSession = Depends(get_db),
126
+ ) -> dict:
127
+ """
128
+ Ejecuta una consulta SQL personalizada (solo SELECT).
129
+
130
+ Args:
131
+ body: {"sql": "SELECT ..."}
132
+ session: Sesión de BD.
133
+
134
+ Returns:
135
+ Dict con `rows` y `count`.
136
+
137
+ Raises:
138
+ HTTPException: Si la consulta es inválida o falla.
139
+ """
140
+ sql = body.get("sql", "").strip()
141
+ if not sql:
142
+ raise HTTPException(status_code=400, detail="Falta la consulta SQL.")
143
+
144
+ sql = _validate_query(sql)
145
+
146
+ # Añadir LIMIT si no existe
147
+ if "LIMIT" not in sql.upper():
148
+ sql = f"{sql} LIMIT {MAX_ROWS}"
149
+
150
+ try:
151
+ result = await session.execute(text(sql))
152
+ rows = [dict(row._mapping) for row in result.fetchall()]
153
+ # Convertir tipos no serializables
154
+ for row in rows:
155
+ for k, v in row.items():
156
+ if hasattr(v, "isoformat"):
157
+ row[k] = v.isoformat()
158
+ elif hasattr(v, "__geo_interface__"):
159
+ row[k] = dict(v.__geo_interface__)
160
+ return {"rows": rows, "count": len(rows)}
161
+ except Exception as e:
162
+ raise HTTPException(status_code=500, detail=str(e)) from e
backend/api/deps.py CHANGED
@@ -1,21 +1,21 @@
1
  """
2
  Archivo: deps.py
3
- Fecha de modificación: 03/06/2026
4
  Autor: Equipo AgroVisión
5
 
6
  Descripción:
7
  Dependencias compartidas de la API (gateway): el proxy **efímero** de credenciales
8
  BYOK y la sesión de base de datos. Las llaves del usuario llegan por cabeceras
9
- `X-User-*` (modelo de cero persistencia) y, para desarrollo local, hacen *fallback* a
10
- las variables `DEV_*`/`SUPABASE_*` del entorno. La `UserKeys` vive solo durante el
11
- request: nunca se escribe a disco, log ni BD.
12
 
13
  Acciones Principales:
14
- - `get_user_keys`: extrae las llaves del request (header o entorno DEV).
15
  - `get_db`: cede una sesión async de SQLAlchemy.
16
 
17
  Entradas / Dependencias:
18
- - `fastapi.Header`, `backend.db.session`.
19
 
20
  Salidas / Efectos:
21
  - Abre una sesión por request; no persiste credenciales.
@@ -33,6 +33,7 @@ from dataclasses import dataclass
33
  from fastapi import Header
34
  from sqlalchemy.ext.asyncio import AsyncSession
35
 
 
36
  from backend.db.session import get_sessionmaker
37
 
38
 
@@ -55,19 +56,30 @@ def get_user_keys(
55
  x_user_supabase_key: str | None = Header(default=None),
56
  ) -> UserKeys:
57
  """
58
- Construye las `UserKeys` del request: cabecera primero, entorno DEV como fallback.
59
 
60
- El fallback permite el desarrollo local (las llaves en `.env` como `DEV_*`), pero en
61
- producción siempre llegan por cabecera desde la UI y se descartan tras el request.
 
 
 
62
 
63
  Returns:
64
  UserKeys: Credenciales de la sesión (nunca se persisten).
65
  """
 
 
 
 
 
 
 
 
66
  return UserKeys(
67
- groq=x_user_groq_key or os.getenv("DEV_GROQ_API_KEY") or None,
68
- copernicus_id=x_user_copernicus_id or os.getenv("DEV_COPERNICUS_CLIENT_ID") or None,
69
  copernicus_secret=(
70
- x_user_copernicus_secret or os.getenv("DEV_COPERNICUS_CLIENT_SECRET") or None
71
  ),
72
  supabase_url=x_user_supabase_url or os.getenv("SUPABASE_URL") or None,
73
  supabase_key=x_user_supabase_key or os.getenv("SUPABASE_ANON_KEY") or None,
 
1
  """
2
  Archivo: deps.py
3
+ Fecha de modificación: 04/06/2026
4
  Autor: Equipo AgroVisión
5
 
6
  Descripción:
7
  Dependencias compartidas de la API (gateway): el proxy **efímero** de credenciales
8
  BYOK y la sesión de base de datos. Las llaves del usuario llegan por cabeceras
9
+ `X-User-*` (modelo de cero persistencia). En **desarrollo local** (`APP_ENV=development`),
10
+ hacen *fallback* a las variables `DEV_*`/`SUPABASE_*` del `.env`. En **producción**,
11
+ las credenciales son **obligatorias** vía cabeceras (cada usuario pone las suyas).
12
 
13
  Acciones Principales:
14
+ - `get_user_keys`: extrae las llaves del request (header o entorno DEV en local).
15
  - `get_db`: cede una sesión async de SQLAlchemy.
16
 
17
  Entradas / Dependencias:
18
+ - `fastapi.Header`, `backend.db.session`, `backend.config`.
19
 
20
  Salidas / Efectos:
21
  - Abre una sesión por request; no persiste credenciales.
 
33
  from fastapi import Header
34
  from sqlalchemy.ext.asyncio import AsyncSession
35
 
36
+ from backend.config import get_settings
37
  from backend.db.session import get_sessionmaker
38
 
39
 
 
56
  x_user_supabase_key: str | None = Header(default=None),
57
  ) -> UserKeys:
58
  """
59
+ Construye las `UserKeys` del request.
60
 
61
+ En **desarrollo local** (`APP_ENV=development`): cabecera primero, entorno DEV como
62
+ fallback (permite trabajar sin reescribir llaves en la UI).
63
+
64
+ En **producción** (`APP_ENV=production`): **solo cabeceras**. Cada usuario debe
65
+ proporcionar sus propias credenciales vía la pestaña Credenciales de la UI.
66
 
67
  Returns:
68
  UserKeys: Credenciales de la sesión (nunca se persisten).
69
  """
70
+ settings = get_settings()
71
+ is_dev = settings.app_env == "development"
72
+
73
+ # En producción, NO usar fallback del entorno (cada usuario pone sus llaves)
74
+ dev_groq = os.getenv("DEV_GROQ_API_KEY") if is_dev else None
75
+ dev_cop_id = os.getenv("DEV_COPERNICUS_CLIENT_ID") if is_dev else None
76
+ dev_cop_secret = os.getenv("DEV_COPERNICUS_CLIENT_SECRET") if is_dev else None
77
+
78
  return UserKeys(
79
+ groq=x_user_groq_key or dev_groq or None,
80
+ copernicus_id=x_user_copernicus_id or dev_cop_id or None,
81
  copernicus_secret=(
82
+ x_user_copernicus_secret or dev_cop_secret or None
83
  ),
84
  supabase_url=x_user_supabase_url or os.getenv("SUPABASE_URL") or None,
85
  supabase_key=x_user_supabase_key or os.getenv("SUPABASE_ANON_KEY") or None,
backend/main.py CHANGED
@@ -45,6 +45,7 @@ from fastapi.staticfiles import StaticFiles
45
  from backend.api.chat import router as chat_router
46
  from backend.api.count import router as count_router
47
  from backend.api.credentials import router as credentials_router
 
48
  from backend.api.events import router as events_router
49
  from backend.api.fields import router as fields_router
50
  from backend.api.ndvi import router as ndvi_router
@@ -148,6 +149,7 @@ def create_app() -> FastAPI:
148
  app.include_router(chat_router) # Asistente Agéntico (RAG)
149
  app.include_router(credentials_router) # Presencia de .env (dev)
150
  app.include_router(events_router) # Telemetría de UI (Fase 9)
 
151
  app.include_router(count_router) # Conteo (en desarrollo / standby)
152
 
153
  # UI Astro estática (Fase 8): se monta en "/" AL FINAL, tras los routers /api y
 
45
  from backend.api.chat import router as chat_router
46
  from backend.api.count import router as count_router
47
  from backend.api.credentials import router as credentials_router
48
+ from backend.api.data import router as data_router
49
  from backend.api.events import router as events_router
50
  from backend.api.fields import router as fields_router
51
  from backend.api.ndvi import router as ndvi_router
 
149
  app.include_router(chat_router) # Asistente Agéntico (RAG)
150
  app.include_router(credentials_router) # Presencia de .env (dev)
151
  app.include_router(events_router) # Telemetría de UI (Fase 9)
152
+ app.include_router(data_router) # Explorador de Datos (Fase 11)
153
  app.include_router(count_router) # Conteo (en desarrollo / standby)
154
 
155
  # UI Astro estática (Fase 8): se monta en "/" AL FINAL, tras los routers /api y
frontend/src/pages/index.astro CHANGED
@@ -101,6 +101,9 @@ import "../styles/global.css";
101
  <button data-tab="tab-chat" class="nav-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-semibold text-stone-400 hover:bg-stone-800 hover:text-white transition-all">
102
  <i class="fa-solid fa-robot w-5"></i> Asistente Agéntico
103
  </button>
 
 
 
104
  <button data-tab="tab-config" class="nav-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-semibold text-stone-400 hover:bg-stone-800 hover:text-white transition-all">
105
  <i class="fa-solid fa-sliders w-5"></i> Credenciales & APIs
106
  </button>
@@ -255,11 +258,57 @@ import "../styles/global.css";
255
  </div>
256
  </section>
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  <!-- CREDENCIALES -->
259
  <section id="tab-config" class="tab-content space-y-6">
260
- <div class="bg-amber-50 border border-amber-300 text-amber-900 p-4 rounded-xl text-sm">
261
  ⚠️ <strong>Tus credenciales se usan solo en esta sesión. No se guardan ni almacenan.</strong> Si actualizas o cierras la página, todo se borrará.
262
  </div>
 
 
 
263
  <div class="bg-white p-6 rounded-xl border border-stone-200 shadow-sm grid grid-cols-1 md:grid-cols-2 gap-5">
264
  <div><label class="block text-xs font-bold text-stone-600 mb-1">Groq API Key (asistente RAG)</label><input id="in-groq" type="password" placeholder="gsk_..." class="w-full text-xs p-3 border border-stone-300 rounded-lg bg-stone-50" /></div>
265
  <div><label class="block text-xs font-bold text-stone-600 mb-1">Copernicus Client ID</label><input id="in-copid" type="password" placeholder="sh-..." class="w-full text-xs p-3 border border-stone-300 rounded-lg bg-stone-50" /></div>
@@ -347,6 +396,7 @@ import "../styles/global.css";
347
  "tab-satelite": ["Teledetección (GIS)", "NDVI 5 años + clima + heatmap (solo lectura)."],
348
  "tab-vision": ["Detección por Dron", "Conteo de plantas — EN DESARROLLO."],
349
  "tab-chat": ["Asistente Agéntico", "Agente RAG sobre tus parcelas."],
 
350
  "tab-config": ["Credenciales & APIs", "Llaves efímeras (solo en esta sesión)."],
351
  };
352
  function switchTab(id) {
@@ -362,6 +412,7 @@ import "../styles/global.css";
362
  if (id === "tab-parcelas") setTimeout(initMap, 50);
363
  if (id === "tab-satelite") setTimeout(() => { loadTeledeteccion(); }, 50);
364
  if (id === "tab-resumen") loadResumen();
 
365
  if (window.innerWidth < 768) closeSidebar();
366
  }
367
  document.querySelectorAll(".nav-btn").forEach((b) =>
@@ -563,7 +614,56 @@ import "../styles/global.css";
563
  $("btn-chat").addEventListener("click", sendChat);
564
  $("chat-input").addEventListener("keydown", (e) => { if (e.key === "Enter") sendChat(); });
565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  // ===== Credenciales (efímeras) =====
 
567
  $("btn-save-creds").addEventListener("click", () => {
568
  creds = { groq: $("in-groq").value, copId: $("in-copid").value, copSecret: $("in-copsecret").value, sbUrl: $("in-sburl").value, sbKey: $("in-sbkey").value };
569
  const n = Object.values(creds).filter(Boolean).length;
@@ -577,11 +677,16 @@ import "../styles/global.css";
577
  const r = await fetch("./api/credentials/status");
578
  if (!r.ok) return notify("No disponible", "No se pudo consultar el .env del servidor.");
579
  const s = await r.json();
 
580
  const have = Object.entries({ Groq: s.groq, Copernicus: s.copernicus, Supabase: s.supabase }).filter(([, v]) => v).map(([k]) => k);
581
- track("env_check", { have });
582
- $("env-status").innerHTML = have.length
583
- ? `✅ En el servidor (.env): <b>${have.join(", ")}</b>. Se usan automáticamente; no necesitas reescribirlas aquí.`
584
- : "No se detectaron credenciales en el .env del servidor.";
 
 
 
 
585
  } catch { notify("Error", "No se pudo consultar el .env del servidor."); }
586
  });
587
 
@@ -599,6 +704,18 @@ import "../styles/global.css";
599
  $("db-status-text").innerText = ok ? "Backend conectado" : "Backend no disponible";
600
  if (ok) { $("db-status-text").classList.replace("text-stone-400", "text-emerald-400"); }
601
  } catch { $("db-status-text").innerText = "Backend no disponible"; }
 
 
 
 
 
 
 
 
 
 
 
 
602
  await refreshParcels();
603
  const initial = (location.hash || "#tab-resumen").slice(1);
604
  switchTab(document.getElementById(initial) ? initial : "tab-resumen");
 
101
  <button data-tab="tab-chat" class="nav-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-semibold text-stone-400 hover:bg-stone-800 hover:text-white transition-all">
102
  <i class="fa-solid fa-robot w-5"></i> Asistente Agéntico
103
  </button>
104
+ <button data-tab="tab-data" class="nav-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-semibold text-stone-400 hover:bg-stone-800 hover:text-white transition-all">
105
+ <i class="fa-solid fa-database w-5"></i> Explorador de Datos
106
+ </button>
107
  <button data-tab="tab-config" class="nav-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-semibold text-stone-400 hover:bg-stone-800 hover:text-white transition-all">
108
  <i class="fa-solid fa-sliders w-5"></i> Credenciales & APIs
109
  </button>
 
258
  </div>
259
  </section>
260
 
261
+ <!-- EXPLORADOR DE DATOS (Fase 11) -->
262
+ <section id="tab-data" class="tab-content space-y-6">
263
+ <div class="bg-white p-6 rounded-xl border border-stone-200 shadow-sm">
264
+ <h3 class="text-base font-bold text-stone-800 mb-2">Explorador de Datos</h3>
265
+ <p class="text-sm text-stone-600 leading-relaxed">
266
+ Consulta las tablas de tu base de datos Supabase directamente. Selecciona una tabla para ver sus registros o ejecuta consultas SQL personalizadas.
267
+ </p>
268
+ </div>
269
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
270
+ <!-- Selector de tabla -->
271
+ <div class="bg-white p-5 rounded-xl border border-stone-200 shadow-sm lg:col-span-4">
272
+ <h3 class="font-bold text-sm text-stone-800 mb-3">Tablas disponibles</h3>
273
+ <div class="space-y-2" id="tables-list">
274
+ <p class="text-xs text-stone-500">Cargando tablas...</p>
275
+ </div>
276
+ </div>
277
+ <!-- Vista de datos -->
278
+ <div class="bg-white p-5 rounded-xl border border-stone-200 shadow-sm lg:col-span-8">
279
+ <div class="flex items-center justify-between mb-3">
280
+ <h3 class="font-bold text-sm text-stone-800" id="data-view-title">Selecciona una tabla</h3>
281
+ <span class="text-[10px] text-stone-500" id="data-view-count"></span>
282
+ </div>
283
+ <div id="data-view-content" class="overflow-x-auto max-h-[400px] overflow-y-auto">
284
+ <p class="text-xs text-stone-500">Los datos aparecerán aquí.</p>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ <!-- Consulta SQL personalizada -->
289
+ <div class="bg-white p-5 rounded-xl border border-stone-200 shadow-sm">
290
+ <h3 class="font-bold text-sm text-stone-800 mb-3">Consulta SQL personalizada</h3>
291
+ <textarea id="sql-query" rows="4" placeholder="SELECT * FROM fields LIMIT 10;" class="w-full text-xs p-3 border border-stone-300 rounded-lg bg-stone-50 font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500 mb-3"></textarea>
292
+ <div class="flex gap-2">
293
+ <button id="btn-run-query" class="bg-emerald-700 hover:bg-emerald-800 text-white font-bold text-xs px-4 py-2 rounded-lg">
294
+ <i class="fa-solid fa-play"></i> Ejecutar
295
+ </button>
296
+ <button id="btn-clear-query" class="bg-stone-100 hover:bg-stone-200 text-stone-800 font-bold text-xs px-4 py-2 rounded-lg">
297
+ <i class="fa-solid fa-eraser"></i> Limpiar
298
+ </button>
299
+ </div>
300
+ <div id="query-result" class="mt-4 overflow-x-auto max-h-[300px] overflow-y-auto"></div>
301
+ </div>
302
+ </section>
303
+
304
  <!-- CREDENCIALES -->
305
  <section id="tab-config" class="tab-content space-y-6">
306
+ <div id="creds-warning" class="bg-amber-50 border border-amber-300 text-amber-900 p-4 rounded-xl text-sm">
307
  ⚠️ <strong>Tus credenciales se usan solo en esta sesión. No se guardan ni almacenan.</strong> Si actualizas o cierras la página, todo se borrará.
308
  </div>
309
+ <div id="creds-production-warning" class="hidden bg-red-50 border border-red-300 text-red-900 p-4 rounded-xl text-sm">
310
+ 🌐 <strong>Entorno de producción:</strong> Debes ingresar tus propias credenciales para usar la plataforma. Las credenciales del servidor no están disponibles en este entorno.
311
+ </div>
312
  <div class="bg-white p-6 rounded-xl border border-stone-200 shadow-sm grid grid-cols-1 md:grid-cols-2 gap-5">
313
  <div><label class="block text-xs font-bold text-stone-600 mb-1">Groq API Key (asistente RAG)</label><input id="in-groq" type="password" placeholder="gsk_..." class="w-full text-xs p-3 border border-stone-300 rounded-lg bg-stone-50" /></div>
314
  <div><label class="block text-xs font-bold text-stone-600 mb-1">Copernicus Client ID</label><input id="in-copid" type="password" placeholder="sh-..." class="w-full text-xs p-3 border border-stone-300 rounded-lg bg-stone-50" /></div>
 
396
  "tab-satelite": ["Teledetección (GIS)", "NDVI 5 años + clima + heatmap (solo lectura)."],
397
  "tab-vision": ["Detección por Dron", "Conteo de plantas — EN DESARROLLO."],
398
  "tab-chat": ["Asistente Agéntico", "Agente RAG sobre tus parcelas."],
399
+ "tab-data": ["Explorador de Datos", "Consulta tus datos directamente (Fase 11)."],
400
  "tab-config": ["Credenciales & APIs", "Llaves efímeras (solo en esta sesión)."],
401
  };
402
  function switchTab(id) {
 
412
  if (id === "tab-parcelas") setTimeout(initMap, 50);
413
  if (id === "tab-satelite") setTimeout(() => { loadTeledeteccion(); }, 50);
414
  if (id === "tab-resumen") loadResumen();
415
+ if (id === "tab-data") loadDataExplorer();
416
  if (window.innerWidth < 768) closeSidebar();
417
  }
418
  document.querySelectorAll(".nav-btn").forEach((b) =>
 
614
  $("btn-chat").addEventListener("click", sendChat);
615
  $("chat-input").addEventListener("keydown", (e) => { if (e.key === "Enter") sendChat(); });
616
 
617
+ // ===== Explorador de Datos (Fase 11) =====
618
+ const TABLES = ["fields", "ndvi_timeseries", "chat_messages", "events"];
619
+ async function loadDataExplorer() {
620
+ const list = $("tables-list");
621
+ list.innerHTML = TABLES.map((t) =>
622
+ `<button data-table="${t}" class="w-full text-left text-xs px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 hover:bg-stone-100 font-semibold text-stone-700"><i class="fa-solid fa-table mr-1 text-emerald-600"></i>${t}</button>`
623
+ ).join("");
624
+ list.querySelectorAll("button").forEach((b) =>
625
+ b.addEventListener("click", () => loadTableData(b.dataset.table))
626
+ );
627
+ }
628
+ async function loadTableData(table) {
629
+ $("data-view-title").innerText = `Tabla: ${table}`;
630
+ $("data-view-content").innerHTML = '<p class="text-xs text-stone-500">Cargando...</p>';
631
+ try {
632
+ const r = await api(`/data/${table}`);
633
+ if (!r.ok) { $("data-view-content").innerHTML = '<p class="text-xs text-red-500">Error al cargar. Verifica credenciales.</p>'; return; }
634
+ const d = await r.json();
635
+ const rows = d.rows || [];
636
+ $("data-view-count").innerText = `${rows.length} registros`;
637
+ if (!rows.length) { $("data-view-content").innerHTML = '<p class="text-xs text-stone-500">Sin registros.</p>'; return; }
638
+ const cols = Object.keys(rows[0]);
639
+ const html = `<table class="w-full text-[10px] border-collapse"><thead><tr>${cols.map((c) => `<th class="border border-stone-200 px-2 py-1 bg-stone-100 font-bold text-stone-700">${c}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${cols.map((c) => `<td class="border border-stone-200 px-2 py-1">${formatCell(row[c])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
640
+ $("data-view-content").innerHTML = html;
641
+ } catch (e) { $("data-view-content").innerHTML = `<p class="text-xs text-red-500">Error: ${e}</p>`; }
642
+ }
643
+ function formatCell(v) {
644
+ if (v === null || v === undefined) return '<span class="text-stone-400">NULL</span>';
645
+ if (typeof v === "object") return `<code class="text-[9px] bg-stone-100 px-1 rounded">${JSON.stringify(v).slice(0, 80)}</code>`;
646
+ return String(v).slice(0, 100);
647
+ }
648
+ $("btn-run-query").addEventListener("click", async () => {
649
+ const sql = $("sql-query").value.trim();
650
+ if (!sql) return notify("Falta la consulta", "Escribe una consulta SQL.");
651
+ $("query-result").innerHTML = '<p class="text-xs text-stone-500">Ejecutando...</p>';
652
+ try {
653
+ const r = await api("/data/query", { method: "POST", body: JSON.stringify({ sql }) });
654
+ if (!r.ok) { const e = await r.json().catch(() => ({})); $("query-result").innerHTML = `<p class="text-xs text-red-500">Error: ${e.detail || r.status}</p>`; return; }
655
+ const d = await r.json();
656
+ const rows = d.rows || [];
657
+ if (!rows.length) { $("query-result").innerHTML = '<p class="text-xs text-stone-500">Sin resultados.</p>'; return; }
658
+ const cols = Object.keys(rows[0]);
659
+ const html = `<table class="w-full text-[10px] border-collapse"><thead><tr>${cols.map((c) => `<th class="border border-stone-200 px-2 py-1 bg-stone-100 font-bold text-stone-700">${c}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${cols.map((c) => `<td class="border border-stone-200 px-2 py-1">${formatCell(row[c])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
660
+ $("query-result").innerHTML = html;
661
+ } catch (e) { $("query-result").innerHTML = `<p class="text-xs text-red-500">Error: ${e}</p>`; }
662
+ });
663
+ $("btn-clear-query").addEventListener("click", () => { $("sql-query").value = ""; $("query-result").innerHTML = ""; });
664
+
665
  // ===== Credenciales (efímeras) =====
666
+ let appEnv = "development"; // se actualiza en init()
667
  $("btn-save-creds").addEventListener("click", () => {
668
  creds = { groq: $("in-groq").value, copId: $("in-copid").value, copSecret: $("in-copsecret").value, sbUrl: $("in-sburl").value, sbKey: $("in-sbkey").value };
669
  const n = Object.values(creds).filter(Boolean).length;
 
677
  const r = await fetch("./api/credentials/status");
678
  if (!r.ok) return notify("No disponible", "No se pudo consultar el .env del servidor.");
679
  const s = await r.json();
680
+ appEnv = s.env || "development";
681
  const have = Object.entries({ Groq: s.groq, Copernicus: s.copernicus, Supabase: s.supabase }).filter(([, v]) => v).map(([k]) => k);
682
+ track("env_check", { have, env: appEnv });
683
+ if (appEnv === "production") {
684
+ $("env-status").innerHTML = " <b>Producción:</b> cada usuario debe ingresar sus propias credenciales.";
685
+ } else {
686
+ $("env-status").innerHTML = have.length
687
+ ? `✅ En el servidor (.env): <b>${have.join(", ")}</b>. Se usan automáticamente; no necesitas reescribirlas aquí.`
688
+ : "No se detectaron credenciales en el .env del servidor.";
689
+ }
690
  } catch { notify("Error", "No se pudo consultar el .env del servidor."); }
691
  });
692
 
 
704
  $("db-status-text").innerText = ok ? "Backend conectado" : "Backend no disponible";
705
  if (ok) { $("db-status-text").classList.replace("text-stone-400", "text-emerald-400"); }
706
  } catch { $("db-status-text").innerText = "Backend no disponible"; }
707
+ // Detectar entorno y mostrar aviso si es producción
708
+ try {
709
+ const r = await fetch("./api/credentials/status");
710
+ if (r.ok) {
711
+ const s = await r.json();
712
+ appEnv = s.env || "development";
713
+ if (appEnv === "production") {
714
+ $("creds-production-warning").classList.remove("hidden");
715
+ $("btn-check-env").style.display = "none";
716
+ }
717
+ }
718
+ } catch {}
719
  await refreshParcels();
720
  const initial = (location.hash || "#tab-resumen").slice(1);
721
  switchTab(document.getElementById(initial) ? initial : "tab-resumen");