diff --git a/dashboard/tablero_server.py b/dashboard/tablero_server.py index f4bf86a..2c23f13 100644 --- a/dashboard/tablero_server.py +++ b/dashboard/tablero_server.py @@ -56,6 +56,7 @@ DEFAULT_ACTA_CONFIG: dict = { } _CLIENT_GONE_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) +_ALL_TEAMS_KEY = "__all__" def load_acta_config() -> dict: @@ -154,6 +155,30 @@ def schedule_open_browser(port: int) -> None: threading.Thread(target=_open, daemon=True).start() +def _team_cache_key(group_id: str | None) -> str: + gid = (group_id or "").strip() + return gid if gid else _ALL_TEAMS_KEY + + +def fetch_teams_list() -> list[dict[str, str]]: + """Lista equipos de Teams con Planner (Graph). Vacio si la fuente no lo soporta.""" + service = create_data_service() + list_fn = getattr(service, "get_groups_with_planner", None) + if not callable(list_fn): + return [] + groups = list_fn() + teams = [ + { + "id": str(g.get("id", "")).strip(), + "name": str(g.get("displayName") or g.get("id") or "").strip(), + } + for g in groups + if g.get("id") + ] + teams.sort(key=lambda t: t["name"].casefold()) + return teams + + class DashboardCache: def __init__( self, @@ -167,9 +192,11 @@ class DashboardCache: self._on_updated = on_updated self._data_lock = threading.Lock() self._refresh_lock = threading.Lock() - self._refreshing = False - self._cached_at = 0.0 - self._cached_tsv = "" + self._refreshing_keys: set[str] = set() + self._tsv_entries: dict[str, tuple[str, float]] = {} + self._teams: list[dict[str, str]] = [] + self._teams_cached_at = 0.0 + self._teams_ttl = max(ttl_seconds * 6, 60) self._last_error = "" self._last_background_at = 0.0 @@ -180,88 +207,127 @@ class DashboardCache: except Exception: pass - def _fetch_fresh(self) -> str: + def _fetch_fresh_tsv(self, group_id: str | None = None) -> str: service = create_data_service() - projects = service.get_all_projects() + gid = (group_id or "").strip() + group_ids = [gid] if gid else None + projects = service.get_all_projects(group_ids=group_ids) return projects_to_tsv(projects) - def _cache_age(self) -> float: - if not self._cached_tsv: + def _entry_age(self, key: str) -> float: + entry = self._tsv_entries.get(key) + if not entry: return float("inf") - return time.time() - self._cached_at + return time.time() - entry[1] - def _is_stale(self) -> bool: - if not self._cached_tsv: - return True - return self._cache_age() >= self.ttl_seconds + def _is_stale(self, key: str) -> bool: + return key not in self._tsv_entries or self._entry_age(key) >= self.ttl_seconds - def _should_background_refresh(self) -> bool: - if self._refreshing: + def _get_cached_tsv(self, key: str) -> str: + entry = self._tsv_entries.get(key) + return entry[0] if entry else "" + + def _should_background_refresh(self, key: str) -> bool: + if key in self._refreshing_keys: return False since_bg = time.time() - self._last_background_at if since_bg < self.min_background_seconds: return False - return self._is_stale() + return self._is_stale(key) - def _start_background_refresh(self) -> None: + def _start_background_refresh(self, group_id: str | None = None) -> None: + key = _team_cache_key(group_id) with self._refresh_lock: - if self._refreshing: + if key in self._refreshing_keys: return - self._refreshing = True + self._refreshing_keys.add(key) self._last_background_at = time.time() def _run() -> None: try: - self._refresh() + self._refresh(group_id) finally: with self._refresh_lock: - self._refreshing = False + self._refreshing_keys.discard(key) threading.Thread(target=_run, daemon=True).start() - def _refresh(self) -> tuple[str, bool]: + def _refresh_stale_entries(self) -> None: + with self._data_lock: + keys = list(self._tsv_entries.keys()) + for key in keys: + if self._is_stale(key): + gid = None if key == _ALL_TEAMS_KEY else key + self._start_background_refresh(gid) + + def _refresh(self, group_id: str | None = None) -> tuple[str, bool]: + key = _team_cache_key(group_id) try: - fresh = self._fetch_fresh() + fresh = self._fetch_fresh_tsv(group_id) except Exception as exc: self._last_error = str(exc) - with self._data_lock: - if self._cached_tsv: - return self._cached_tsv, True + cached = self._get_cached_tsv(key) + if cached: + return cached, True raise with self._data_lock: - self._cached_tsv = fresh - self._cached_at = time.time() + self._tsv_entries[key] = (fresh, time.time()) self._last_error = "" self._notify_updated() - return self._cached_tsv, False + return fresh, False + + def get_tsv(self, *, force: bool = False, group_id: str | None = None) -> tuple[str, bool]: + """Devuelve (tsv, from_cache) para un equipo o todos (__all__).""" + key = _team_cache_key(group_id) + + if force: + with self._refresh_lock: + if key in self._refreshing_keys: + cached = self._get_cached_tsv(key) + if cached: + return cached, True + refreshed, _ = self._refresh(group_id) + return refreshed, False - def get_tsv(self, *, force: bool = False) -> tuple[str, bool]: - """Devuelve (tsv, from_cache). Sirve cache mientras Power Automate responde.""" with self._data_lock: - cached = self._cached_tsv - stale = self._is_stale() + cached = self._get_cached_tsv(key) + stale = self._is_stale(key) - if force or stale: - if self._should_background_refresh(): - self._start_background_refresh() + if stale and cached: + if self._should_background_refresh(key): + self._start_background_refresh(group_id) + return cached, True if cached and not stale: return cached, True - if cached: - return cached, True + if self._should_background_refresh(key): + self._start_background_refresh(group_id) with self._refresh_lock: - already_refreshing = self._refreshing - if already_refreshing: + refreshing = key in self._refreshing_keys + if refreshing: deadline = time.time() + 5 while time.time() < deadline: with self._data_lock: - if self._cached_tsv: - return self._cached_tsv, True + cached = self._get_cached_tsv(key) + if cached: + return cached, True time.sleep(0.2) - return self._refresh() + return self._refresh(group_id) + + def get_teams(self, *, force: bool = False) -> list[dict[str, str]]: + with self._data_lock: + teams = list(self._teams) + age = time.time() - self._teams_cached_at + if teams and not force and age < self._teams_ttl: + return teams + fresh = fetch_teams_list() + with self._data_lock: + self._teams = fresh + self._teams_cached_at = time.time() + return list(fresh) @property def last_error(self) -> str: @@ -280,7 +346,8 @@ def create_handler( def refresh_and_notify() -> None: try: cache._last_background_at = 0.0 - cache._start_background_refresh() + cache._start_background_refresh(None) + cache._refresh_stale_entries() print("[WEBHOOK] Actualizacion desde Planner programada.", flush=True) except Exception as exc: event_hub.broadcast("planner-error", json.dumps({"error": str(exc)})) @@ -316,6 +383,10 @@ def create_handler( self._serve_dashboard_tsv(parsed) return + if route == "/api/groups": + self._serve_groups(parsed) + return + if route == "/api/health": self._serve_health() return @@ -392,12 +463,14 @@ def create_handler( self._write_body(body) def _serve_dashboard_tsv(self, parsed) -> None: - force = "refresh" in parse_qs(parsed.query) + query = parse_qs(parsed.query) + force = "refresh" in query + group_id = query.get("group_id", [""])[0].strip() or None try: - tsv, from_cache = cache.get_tsv(force=force) + tsv, from_cache = cache.get_tsv(force=force, group_id=group_id) if not tsv.strip(): raise RuntimeError( - "Sin datos en cache. Power Automate aun no respondio; reintenta en unos segundos." + "Sin datos en cache. Planner aun no respondio; reintenta en unos segundos." ) body = tsv.encode("utf-8") cache_status = "HIT" if from_cache else "MISS" @@ -406,6 +479,8 @@ def create_handler( self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("X-Cache", cache_status) + if group_id: + self.send_header("X-Group-Id", group_id) self.end_headers() self._write_body(body) except _CLIENT_GONE_ERRORS: @@ -422,6 +497,35 @@ def create_handler( except _CLIENT_GONE_ERRORS: pass + def _serve_groups(self, parsed) -> None: + query = parse_qs(parsed.query) + force = "refresh" in query + try: + teams = cache.get_teams(force=force) + payload = {"teams": teams} + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self._write_body(body) + except _CLIENT_GONE_ERRORS: + pass + except Exception as exc: + body = json.dumps( + {"teams": [], "error": str(exc)}, + ensure_ascii=False, + ).encode("utf-8") + try: + self.send_response(502) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self._write_body(body) + except _CLIENT_GONE_ERRORS: + pass + def _serve_health(self) -> None: payload = { "ok": True, @@ -587,17 +691,19 @@ def run_server( def _warm_cache() -> None: print("[...] Cargando datos iniciales de Planner (puede tardar 1-3 min)...", flush=True) + key = _ALL_TEAMS_KEY with cache._refresh_lock: - cache._refreshing = True + cache._refreshing_keys.add(key) try: cache._last_background_at = 0.0 - cache._refresh() + cache._refresh(None) + cache.get_teams() print("[OK] Cache inicial de Planner listo.", flush=True) except Exception as exc: print(f"[AVISO] Cache inicial no disponible: {exc}", flush=True) finally: with cache._refresh_lock: - cache._refreshing = False + cache._refreshing_keys.discard(key) threading.Thread(target=_warm_cache, daemon=True).start() @@ -605,7 +711,7 @@ def run_server( def _periodic_sync() -> None: while True: time.sleep(DASHBOARD_REFRESH_SECONDS) - cache._start_background_refresh() + cache._refresh_stale_entries() threading.Thread(target=_periodic_sync, daemon=True).start() diff --git a/docker-compose.yml b/docker-compose.yml index 8a38120..9b7d8aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,6 @@ services: DASHBOARD_PORT: "8765" DASHBOARD_OPEN_BROWSER: "false" volumes: - - ./config/acta_config.json:/app/config/acta_config.json + - ./config:/app/config - ./output:/app/output restart: unless-stopped diff --git a/web/Tablero_proyectos_v7.html b/web/Tablero_proyectos_v7.html index 70528c8..d3e43a6 100644 --- a/web/Tablero_proyectos_v7.html +++ b/web/Tablero_proyectos_v7.html @@ -471,6 +471,48 @@ .modal-status { font-size: 0.85rem; margin-top: 8px; min-height: 1.2em; } .modal-status.ok { color: var(--success); } .modal-status.err { color: var(--danger); } + + .team-selector-wrap { + width: 100%; + margin-bottom: 14px; + padding: 0 4px; + } + .team-selector-label { + display: block; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(255,255,255,0.65); + margin-bottom: 6px; + font-weight: 600; + } + .team-selector { + width: 100%; + padding: 10px 12px; + border: 1px solid rgba(255,255,255,0.15); + border-radius: 8px; + background: rgba(0,0,0,0.2); + color: white; + font-size: 0.9rem; + font-family: inherit; + cursor: pointer; + box-sizing: border-box; + } + .team-selector:focus { + outline: none; + border-color: var(--sapian-accent); + box-shadow: 0 0 0 2px rgba(17, 184, 154, 0.25); + } + .team-selector option { + color: #1e293b; + background: #fff; + } + .team-selector-hint { + font-size: 0.72rem; + color: rgba(255,255,255,0.5); + margin-top: 5px; + line-height: 1.3; + }
@@ -480,6 +522,14 @@