Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2096c9b6 | ||
|
|
e63fe39578 | ||
|
|
b7d21f80df | ||
|
|
c7711c0ca2 | ||
|
|
319619d200 | ||
|
|
0bdde28b79 | ||
|
|
4b38acdcba | ||
|
|
760fbd8c6d | ||
|
|
f5d2d7d716 | ||
|
|
71e705ea64 | ||
|
|
7918b54943 | ||
|
|
3695bfac8a | ||
|
|
57d93c4f4b |
+120
@@ -1,5 +1,125 @@
|
||||
# Changelog
|
||||
|
||||
## 0.22.0 — 2026-10-14
|
||||
|
||||
### Écran de connexion (demande de Bruno)
|
||||
|
||||
- Fini la fenêtre Basic Auth du navigateur : page **/login**
|
||||
professionnelle (carte centrée, marque dégradée, erreur
|
||||
claire, retour à la page demandée après connexion).
|
||||
- Session par **cookie signé HMAC** (7 jours, HttpOnly,
|
||||
SameSite=Lax) ; clé `KANBANTE_SESSION_SECRET` ou dérivée des
|
||||
identifiants — changer le mot de passe invalide les sessions.
|
||||
- L'en-tête **Basic reste accepté** pour les scripts ; les API
|
||||
reçoivent 401, seuls les navigateurs sont redirigés. Lien
|
||||
**⏻ Sortir** dans le pied de la barre latérale, `/logout`
|
||||
réel. Protection CSRF sur le formulaire, redirection `next`
|
||||
limitée aux chemins locaux.
|
||||
|
||||
### Barre latérale (demandes de Bruno)
|
||||
|
||||
- Le bouton **Replier** quitte le bas de la barre : c'est une
|
||||
**icône seule, en haut, à droite du bouton recherche**.
|
||||
- **Accordéon exclusif** : ouvrir une section ferme les
|
||||
autres — une seule ouverte à la fois (état mémorisé dans
|
||||
`kanbante-nav-open` ; la section de la page courante prime).
|
||||
|
||||
### Chantier B — instantanés d'arrière-plan (validé par Bruno)
|
||||
|
||||
- Un **rafraîchisseur** recalcule périodiquement les listes
|
||||
coûteuses (index des dépôts, étoiles, issues `all` **et**
|
||||
`open`, jalons — pour chaque projet actif) et les persiste
|
||||
dans SQLite (table `snapshots`, écriture à chaque réponse du
|
||||
client). `KANBANTE_SNAPSHOT_INTERVAL` (défaut 120 s, 0 = off).
|
||||
- Au démarrage, le cache du client est **hydraté** depuis les
|
||||
instantanés (servis périmés + rafraîchis en tâche de fond,
|
||||
mécanique SWR) : la première page après un redémarrage ou un
|
||||
redéploiement ne paie plus Gitea.
|
||||
- Un **webhook Gitea** rafraîchit immédiatement les
|
||||
instantanés du dépôt touché. La fraîcheur est affichée dans
|
||||
le pied de la barre (« 🕐 à l'instant / il y a N min ») :
|
||||
les données Gitea peuvent dater d'un intervalle au plus,
|
||||
le journal local reste instantané.
|
||||
- **Mesuré sur l'instance réelle** (via le proxy de Spark,
|
||||
latences gonflées) : premier chargement à froid 9,4 s sans
|
||||
instantanés → après redémarrage avec instantanés : Projets
|
||||
0,31 s, Focus 0,16 s, Triage 0,07 s, Santé 0,22 s,
|
||||
Prévisions 0,08 s.
|
||||
|
||||
## 0.21.0 — 2026-10-12
|
||||
|
||||
### Sélecteur de dépôt intelligent (Capture éclair, demande de Bruno)
|
||||
|
||||
- Le champ **Dépôt** de /capture propose les dépôts réels dès
|
||||
la frappe : liste filtrée en direct, correspondance sur les
|
||||
deux segments — « kan » suffit pour trouver
|
||||
`Projets/kanbante`, « pr/kan » aussi. Au focus, épinglés (⭐)
|
||||
et dépôts récents d'abord.
|
||||
- **Complétion Tab segment par segment**, comme un shell :
|
||||
« pr » Tab → `Projets/` (le séparateur est posé dès que le
|
||||
nom d'organisation est complet), « kan » Tab → `kanbante`.
|
||||
↑/↓ + Entrée et la souris choisissent dans la liste.
|
||||
- **Validation douce** : un nom qui n'existe pas dans Gitea est
|
||||
signalé sous le champ avant l'envoi.
|
||||
- Nouvel endpoint `GET /api/repos` : liste **exhaustive**
|
||||
(~130 dépôts chez Bruno — la palette Ctrl+K reste plafonnée
|
||||
à 60 pour son propre usage), triée épinglés → récents →
|
||||
archivés. Sélecteur dans `static/js/repo-picker.js`,
|
||||
réutilisable par d'autres formulaires.
|
||||
- Vérifié en Playwright sur les données réelles, geste exact
|
||||
de Bruno inclus : `pr` Tab → `Projets/`, `kan` Tab →
|
||||
`Projets/kanbante`.
|
||||
|
||||
## 0.20.1 — 2026-10-12
|
||||
|
||||
### Correctifs tri/regroupement du dashboard (rapportés par Bruno)
|
||||
|
||||
- **Tri par date des tableaux réparé** : le comparateur partagé
|
||||
lisait les dates ISO avec `parseFloat` — il ne retenait que
|
||||
l'année (« 2026 »), toutes les lignes d'une même année se
|
||||
retrouvaient à égalité et la colonne « Dernière activité » (vue
|
||||
Liste) triait dans un ordre arbitraire. La comparaison n'est
|
||||
numérique que si la valeur entière est un nombre ; les dates
|
||||
se comparent en chaînes ISO. Même piège écarté pour l'échéance
|
||||
des Prévisions.
|
||||
- **Tri et regroupement appliqués aux Épinglés** : la grille
|
||||
épinglée était exclue des deux outils (choix de la 0.18.0) —
|
||||
comme c'est la section regardée en premier, changer le Tri ou
|
||||
cocher « Grouper par propriétaire » semblait ne rien faire.
|
||||
Les quatre grilles sont maintenant traitées pareil.
|
||||
- **Sens de premier clic utile** : « Dernière activité »,
|
||||
« Écart » (Prévisions) et « Zombies » (Santé) trient
|
||||
décroissant dès le premier clic (`data-first-dir`).
|
||||
- Tri grille par dates comparé en instants réels (fuseaux
|
||||
Gitea -04:00/-05:00), cartes sans activité en bas.
|
||||
- Vérifié en Playwright sur les données réelles : ordres
|
||||
strictement décroissant/croissant, regroupement et
|
||||
dégroupement des Épinglés, aucune erreur JS.
|
||||
|
||||
## 0.20.0 — 2026-10-12
|
||||
|
||||
### Refonte de l'en-tête de la page Projets (demande de Bruno)
|
||||
|
||||
- **Titre sobre** : fini le « Bonjour 👋 Voici vos projets
|
||||
Gitea… » — un « Projets » net, un sous-titre discret, et
|
||||
l'en-tête occupe deux fois moins de hauteur qu'avant.
|
||||
- **Bande KPI unifiée** : les 4 statistiques (projets actifs,
|
||||
fermées · 30 j, déplacements · 30 j, temps de cycle moyen)
|
||||
vivent dans UNE barre bordée à séparateurs fins, chiffres en
|
||||
largeur tabulaire ; les indicateurs du journal mènent à la
|
||||
page Activité au clic.
|
||||
- **Barre d'outils unique** : onglets, filtre, tri,
|
||||
regroupement et bascule Grille/Liste — auparavant éparpillés
|
||||
sur trois rangées — sont alignés dans un seul cadre. Les
|
||||
onglets deviennent un contrôle segmenté avec pastilles de
|
||||
compte (plus de dégradé violet ni d'emojis 🟢💤🗄️), le filtre
|
||||
passe en rectangle sobre.
|
||||
- Aucun crochet fonctionnel touché (ids et liaisons Alpine
|
||||
inchangés) ; responsive vérifié en captures 1600 px et
|
||||
430 px sur les données réelles : aucun débordement, onglets
|
||||
fonctionnels. Les tuiles `.stat-tile` restent utilisées par
|
||||
le briefing quotidien.
|
||||
|
||||
## 0.19.0 — 2026-10-12
|
||||
|
||||
### Performance + navigation « Pilotage » (chantiers A + C, décidés par Bruno)
|
||||
|
||||
@@ -112,8 +112,10 @@ sudo systemctl enable --now docker
|
||||
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
|
||||
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
|
||||
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
|
||||
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active la Basic Auth dès que les deux sont définis |
|
||||
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts |
|
||||
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
|
||||
| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) |
|
||||
| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé |
|
||||
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
|
||||
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
|
||||
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
|
||||
|
||||
+13
-3
@@ -8,9 +8,15 @@ Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
GITEA_TOKEN Gitea API token (read/write issues).
|
||||
KANBANTE_DB_PATH Path of the local SQLite database.
|
||||
KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth).
|
||||
KANBANTE_AUTH_PASSWORD Basic-auth password.
|
||||
KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth).
|
||||
KANBANTE_AUTH_PASSWORD Login password.
|
||||
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
|
||||
KANBANTE_SESSION_SECRET HMAC key for the login session cookie
|
||||
(default: derived from the credentials —
|
||||
rotating the password invalidates sessions).
|
||||
KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot
|
||||
refreshes (chantier B, default 120;
|
||||
0 disables the refresher).
|
||||
KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many
|
||||
days counts as "active" (default 365).
|
||||
KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30).
|
||||
@@ -89,7 +95,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.19.0"
|
||||
APP_VERSION = "0.22.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -158,6 +164,8 @@ class Settings:
|
||||
briefing_include_standup: bool = False
|
||||
rules_raw: str = ""
|
||||
zombie_days: int = 30
|
||||
session_secret: str = ""
|
||||
snapshot_interval: int = 120
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -203,4 +211,6 @@ def get_settings() -> Settings:
|
||||
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
||||
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
||||
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
|
||||
session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""),
|
||||
snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120),
|
||||
)
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.19.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.22.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+104
-13
@@ -64,6 +64,10 @@ class GiteaClient:
|
||||
self._cache: dict[str, tuple[float, Any]] = {}
|
||||
self._refreshing: set[str] = set()
|
||||
self._cache_gen = 0
|
||||
# Snapshot bookkeeping (chantier B): cache key -> UTC time
|
||||
# the payload was fetched from Gitea, for the freshness
|
||||
# label and the persisted `snapshots` table.
|
||||
self._snapshot_times: dict[str, str] = {}
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.client.aclose()
|
||||
@@ -112,6 +116,90 @@ class GiteaClient:
|
||||
def _cache_put(self, key: str, value: Any) -> None:
|
||||
if self.cache_ttl > 0:
|
||||
self._cache[key] = (time.monotonic(), value)
|
||||
self._persist_snapshot(key, value)
|
||||
|
||||
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
|
||||
|
||||
def _persist_snapshot(self, key: str, value: Any) -> None:
|
||||
"""Write-through to the SQLite snapshots table.
|
||||
|
||||
Persistence must never break a read: any failure (DB
|
||||
locked, unserializable payload) is logged and swallowed.
|
||||
"""
|
||||
try:
|
||||
from app import models
|
||||
|
||||
models.save_snapshot(key, value)
|
||||
import datetime
|
||||
|
||||
self._snapshot_times[key] = datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception as exc:
|
||||
logger.debug("snapshot persist failed for %s: %s", key, exc)
|
||||
|
||||
def hydrate_from_snapshots(self) -> int:
|
||||
"""Preload the cache from persisted snapshots (startup).
|
||||
|
||||
Entries are inserted with an already-stale timestamp: the
|
||||
SWR layer serves them instantly AND schedules a background
|
||||
refresh, so the first page after a restart renders from
|
||||
local data (tens of ms) while Gitea data catches up.
|
||||
Returns the number of entries hydrated.
|
||||
"""
|
||||
if self.cache_ttl <= 0:
|
||||
return 0
|
||||
try:
|
||||
from app import models
|
||||
|
||||
snapshots = models.load_snapshots()
|
||||
except Exception as exc:
|
||||
logger.warning("snapshot hydration failed: %s", exc)
|
||||
return 0
|
||||
stale_at = time.monotonic() - self.cache_ttl - 1.0
|
||||
for key, (payload, updated_at) in snapshots.items():
|
||||
self._cache[key] = (stale_at, payload)
|
||||
self._snapshot_times[key] = updated_at
|
||||
if snapshots:
|
||||
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
|
||||
return len(snapshots)
|
||||
|
||||
def latest_snapshot_at(self) -> str | None:
|
||||
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
|
||||
if not self._snapshot_times:
|
||||
return None
|
||||
return max(self._snapshot_times.values())
|
||||
|
||||
async def _force(self, key: str, fetcher: Callable[[], Awaitable[Any]]) -> Any:
|
||||
"""Fetch now, bypassing freshness (background refresher)."""
|
||||
value = await fetcher()
|
||||
self._cache_put(key, value)
|
||||
return value
|
||||
|
||||
async def refresh_starred(self) -> list[dict]:
|
||||
return await self._force(
|
||||
"user:starred", lambda: self._get_all_pages("/user/starred")
|
||||
)
|
||||
|
||||
async def refresh_repo_issues(
|
||||
self, owner: str, repo: str, state: str = "all"
|
||||
) -> list[dict]:
|
||||
return await self._force(
|
||||
f"issues:{owner}/{repo}:{state}",
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
),
|
||||
)
|
||||
|
||||
async def refresh_milestones(
|
||||
self, owner: str, repo: str
|
||||
) -> list[dict]:
|
||||
return await self._force(
|
||||
f"milestones:{owner}/{repo}:open",
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state="open"
|
||||
),
|
||||
)
|
||||
|
||||
def _cache_clear(self) -> None:
|
||||
self._cache.clear()
|
||||
@@ -182,22 +270,25 @@ class GiteaClient:
|
||||
self._user = await self._get("/user")
|
||||
return self._user
|
||||
|
||||
async def _fetch_search_repos(self) -> list[dict]:
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug(
|
||||
"get_user failed, falling back to public search: %s", exc
|
||||
)
|
||||
return await self._get_all_pages("/repos/search", **params)
|
||||
|
||||
async def search_repos(self) -> list[dict]:
|
||||
"""All repos visible to the token owner (paginated, SWR-cached)."""
|
||||
return await self._cached_list("search_repos", self._fetch_search_repos)
|
||||
|
||||
async def _fetch() -> list[dict]:
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug(
|
||||
"get_user failed, falling back to public search: %s", exc
|
||||
)
|
||||
return await self._get_all_pages("/repos/search", **params)
|
||||
|
||||
return await self._cached_list("search_repos", _fetch)
|
||||
async def refresh_search_repos(self) -> list[dict]:
|
||||
"""Force-fetch the repo index (background refresher)."""
|
||||
return await self._force("search_repos", self._fetch_search_repos)
|
||||
|
||||
async def get_repo(self, owner: str, repo: str) -> dict:
|
||||
return await self._get(f"/repos/{owner}/{repo}")
|
||||
|
||||
+196
-28
@@ -22,13 +22,21 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from fastapi.responses import (
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
RedirectResponse,
|
||||
Response,
|
||||
StreamingResponse,
|
||||
)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -37,6 +45,7 @@ from app.config import APP_VERSION, get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
from app import code_links as code_links_service
|
||||
from app import digest as digest_service
|
||||
from app import snapshots as snapshots_service
|
||||
from app import flow as flow_service
|
||||
from app import forecast as forecast_service
|
||||
from app import rules as rules_service
|
||||
@@ -155,6 +164,13 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(
|
||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||
)
|
||||
# Chantier B: hydrate the client cache from persisted
|
||||
# snapshots BEFORE serving, then keep them warm in the
|
||||
# background — pages never wait on Gitea when data exists.
|
||||
gitea.hydrate_from_snapshots()
|
||||
snapshot_task = asyncio.create_task(
|
||||
snapshots_service.snapshot_loop(gitea, classify_repos)
|
||||
)
|
||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||
digest_task = asyncio.create_task(_digest_scheduler())
|
||||
rules_task = asyncio.create_task(_rules_scheduler())
|
||||
@@ -179,6 +195,7 @@ async def lifespan(app: FastAPI):
|
||||
settings.capture_ntfy_topic,
|
||||
)
|
||||
yield
|
||||
snapshot_task.cancel()
|
||||
scheduler.cancel()
|
||||
digest_task.cancel()
|
||||
rules_task.cancel()
|
||||
@@ -192,20 +209,81 @@ app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
|
||||
gitea = GiteaClient()
|
||||
|
||||
|
||||
# ── Auth middleware ────────────────────────────────────────
|
||||
# ── Auth: login form + signed session cookie (0.22.0) ──────
|
||||
# Before 0.22.0 the browser got HTTP Basic's native prompt.
|
||||
# Browsers now use a real login page; the session rides in an
|
||||
# HMAC-signed cookie. An Authorization: Basic header is still
|
||||
# honoured for scripts and API clients. /api/health stays
|
||||
# public for Docker's HEALTHCHECK; /webhooks/gitea is
|
||||
# authenticated by its HMAC signature (Gitea cannot log in) and
|
||||
# /api/capture by its dedicated token.
|
||||
|
||||
SESSION_COOKIE = "kanbante_session"
|
||||
SESSION_DAYS = 7
|
||||
_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login")
|
||||
|
||||
|
||||
def _session_secret() -> bytes:
|
||||
settings = get_settings()
|
||||
if settings.session_secret:
|
||||
return settings.session_secret.encode("utf-8")
|
||||
# Derived from the credentials: rotating the password
|
||||
# invalidates every outstanding session (documented trade-off).
|
||||
material = f"kanbante-session:{settings.auth_user}:{settings.auth_password}"
|
||||
return hashlib.sha256(material.encode("utf-8")).digest()
|
||||
|
||||
|
||||
def _make_session(user: str) -> str:
|
||||
exp = int(time.time()) + SESSION_DAYS * 86400
|
||||
payload = f"{user}.{exp}"
|
||||
sig = hmac.new(
|
||||
_session_secret(), payload.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{payload}.{sig}"
|
||||
|
||||
|
||||
def _session_user(request: Request) -> str | None:
|
||||
raw = request.cookies.get(SESSION_COOKIE, "")
|
||||
user, _, rest = raw.partition(".")
|
||||
exp, _, sig = rest.partition(".")
|
||||
if not user or not exp or not sig:
|
||||
return None
|
||||
expected = hmac.new(
|
||||
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not secrets.compare_digest(sig, expected):
|
||||
return None
|
||||
try:
|
||||
if int(exp) < int(time.time()):
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
if not secrets.compare_digest(user, get_settings().auth_user):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def _basic_ok(request: Request) -> bool:
|
||||
settings = get_settings()
|
||||
header = request.headers.get("authorization", "")
|
||||
if not header.lower().startswith("basic "):
|
||||
return False
|
||||
try:
|
||||
decoded = base64.b64decode(header[6:]).decode("utf-8")
|
||||
except Exception:
|
||||
return False
|
||||
user, _, password = decoded.partition(":")
|
||||
return secrets.compare_digest(
|
||||
user, settings.auth_user
|
||||
) and secrets.compare_digest(password, settings.auth_password)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""HTTP Basic auth, enabled only when credentials are configured.
|
||||
|
||||
/api/health stays public so Docker's HEALTHCHECK (liveness) works.
|
||||
When KANBANTE_REQUIRE_AUTH=1 and no credentials are configured, every
|
||||
other route answers 503: failing closed beats failing open.
|
||||
"""
|
||||
"""Session cookie (browser) or Basic header (scripts)."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
|
||||
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
|
||||
if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"):
|
||||
path = request.url.path
|
||||
if path in _PUBLIC_PATHS or path.startswith("/static/"):
|
||||
return await call_next(request)
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled:
|
||||
@@ -220,30 +298,82 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
status_code=503,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
ok = False
|
||||
header = request.headers.get("authorization", "")
|
||||
if header.lower().startswith("basic "):
|
||||
try:
|
||||
decoded = base64.b64decode(header[6:]).decode("utf-8")
|
||||
except Exception:
|
||||
decoded = ""
|
||||
user, _, password = decoded.partition(":")
|
||||
ok = secrets.compare_digest(
|
||||
user, settings.auth_user
|
||||
) and secrets.compare_digest(password, settings.auth_password)
|
||||
if not ok:
|
||||
return Response(
|
||||
"Authentication required",
|
||||
if _session_user(request) is not None or _basic_ok(request):
|
||||
return await call_next(request)
|
||||
# API consumers get a plain 401 (no browser prompt, no
|
||||
# redirect they cannot follow); browsers get the login page.
|
||||
if path.startswith("/api/") or "text/html" not in request.headers.get(
|
||||
"accept", ""
|
||||
):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Authentication required"},
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": 'Basic realm="Kanbante"'},
|
||||
)
|
||||
return await call_next(request)
|
||||
target = path
|
||||
if request.url.query:
|
||||
target = f"{path}?{request.url.query}"
|
||||
return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303)
|
||||
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
|
||||
def _safe_next(raw: str) -> str:
|
||||
"""Only local paths may be a post-login destination."""
|
||||
if raw.startswith("/") and not raw.startswith("//") and "://" not in raw:
|
||||
return raw
|
||||
return "/"
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
async def login_page(request: Request, next: str = "/"):
|
||||
if not get_settings().auth_enabled or _session_user(request) is not None:
|
||||
return RedirectResponse(_safe_next(next), status_code=303)
|
||||
return render(request, "login.html", next=_safe_next(next), error="")
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
async def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(""),
|
||||
password: str = Form(""),
|
||||
next: str = Form("/"),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return render(
|
||||
request, "login.html", status_code=403,
|
||||
next=_safe_next(next), error="Session expirée — réessaie.",
|
||||
)
|
||||
ok = secrets.compare_digest(
|
||||
username, settings.auth_user
|
||||
) and secrets.compare_digest(password, settings.auth_password)
|
||||
if not ok:
|
||||
return render(
|
||||
request, "login.html", status_code=401,
|
||||
next=_safe_next(next), error="Identifiants incorrects.",
|
||||
)
|
||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
_make_session(username),
|
||||
max_age=SESSION_DAYS * 86400,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout():
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
return response
|
||||
|
||||
|
||||
# ── Live updates (SSE hub) ─────────────────────────────────
|
||||
|
||||
class LiveHub:
|
||||
@@ -330,6 +460,8 @@ def render(
|
||||
context.setdefault("csrf_token", token)
|
||||
context.setdefault("version", APP_VERSION)
|
||||
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
|
||||
context.setdefault("auth_enabled", get_settings().auth_enabled)
|
||||
context.setdefault("data_freshness", snapshots_service.freshness_label())
|
||||
html = _tpl_env.get_template(name).render(**context)
|
||||
response = HTMLResponse(html, status_code=status_code)
|
||||
if generated:
|
||||
@@ -2303,6 +2435,13 @@ async def gitea_webhook(request: Request):
|
||||
handled = "pr-merged:" + (",".join(results) if results else "none")
|
||||
|
||||
gitea.invalidate_cache()
|
||||
# Chantier B: a webhook means this repo just changed — refresh
|
||||
# its snapshots right away instead of waiting for the interval.
|
||||
if full_name and "/" in full_name:
|
||||
_owner, _, _repo = full_name.partition("/")
|
||||
asyncio.create_task(
|
||||
snapshots_service.refresh_repo_snapshots(gitea, _owner, _repo)
|
||||
)
|
||||
await hub.broadcast(
|
||||
{"repo": full_name, "event": event, "action": action, "issue": number}
|
||||
)
|
||||
@@ -3513,6 +3652,35 @@ async def api_stats():
|
||||
return get_stats()
|
||||
|
||||
|
||||
@app.get("/api/repos")
|
||||
async def api_repos():
|
||||
"""Every repo, for pickers (capture page repo selector, 0.21.0).
|
||||
|
||||
Unlike /api/palette (capped at 60 for a launcher), a picker
|
||||
must be exhaustive — Bruno has 130+ repos. Ranked for the
|
||||
empty-query case: starred first, archived last, most
|
||||
recently updated first in between.
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
active, inactive, archived = classify_repos(repos)
|
||||
try:
|
||||
starred = {r["full_name"] for r in await gitea.list_starred()}
|
||||
except Exception:
|
||||
starred = set()
|
||||
out = [
|
||||
{
|
||||
"full_name": r["full_name"],
|
||||
"starred": r["full_name"] in starred,
|
||||
"updated_at": r.get("updated_at") or "",
|
||||
"archived": bool(r.get("archived")),
|
||||
}
|
||||
for r in active + inactive + archived
|
||||
]
|
||||
out.sort(key=lambda x: x["updated_at"], reverse=True)
|
||||
out.sort(key=lambda x: (not x["starred"], x["archived"]))
|
||||
return {"repos": out}
|
||||
|
||||
|
||||
@app.get("/api/palette")
|
||||
async def api_palette(q: str = ""):
|
||||
"""Data for the Ctrl+K command palette (roadmap #10).
|
||||
|
||||
@@ -11,6 +11,7 @@ and upgrades older databases in place (position column, notes
|
||||
updated_at, cleanup of rows carrying an unknown column name).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
@@ -91,6 +92,12 @@ def init_db() -> None:
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
key TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_repo
|
||||
ON board_events(repo_full_name, issue_number, created_at);
|
||||
"""
|
||||
@@ -292,6 +299,48 @@ def set_kv(key: str, value: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ── Snapshots (chantier B, 0.22.0) ─────────────────────────
|
||||
# Persisted copies of the Gitea list responses the pages read
|
||||
# (repos, issues, milestones, starred). The client cache is
|
||||
# hydrated from these rows at startup, so even the first page
|
||||
# after a restart renders from local data while the background
|
||||
# refresher re-fetches from Gitea.
|
||||
|
||||
def save_snapshot(key: str, payload) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO snapshots (key, payload, updated_at) "
|
||||
"VALUES (?, ?, datetime('now')) "
|
||||
"ON CONFLICT(key) DO UPDATE SET payload = excluded.payload, "
|
||||
"updated_at = excluded.updated_at",
|
||||
(key, json.dumps(payload)),
|
||||
)
|
||||
|
||||
|
||||
def load_snapshots() -> dict:
|
||||
"""All snapshots as {key: (payload, updated_at_utc_str)}."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key, payload, updated_at FROM snapshots"
|
||||
).fetchall()
|
||||
out = {}
|
||||
for row in rows:
|
||||
try:
|
||||
out[row["key"]] = (json.loads(row["payload"]), row["updated_at"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def latest_snapshot_at() -> str | None:
|
||||
"""UTC timestamp (SQLite format) of the freshest snapshot."""
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(updated_at) AS latest FROM snapshots"
|
||||
).fetchone()
|
||||
return row["latest"] if row else None
|
||||
|
||||
|
||||
# ── Event journal ──────────────────────────────────────────
|
||||
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Background snapshot refresher — chantier B (0.22.0).
|
||||
|
||||
The expensive part of Kanbante is the fan-out of Gitea list
|
||||
calls behind the portfolio pages (repo index, then issues and
|
||||
milestones for every active project). Since 0.19.0 those calls
|
||||
are parallel and stale-while-revalidate, which fixed repeat
|
||||
loads, but the FIRST page after a restart still paid seconds.
|
||||
|
||||
The refresher pays that cost instead of the user: every
|
||||
KANBANTE_SNAPSHOT_INTERVAL seconds it force-refreshes the hot
|
||||
keys (repo index, starred, issues + milestones of active
|
||||
repos). Every fetch write-throughs to the SQLite `snapshots`
|
||||
table, and the client cache is hydrated from that table at
|
||||
startup — so pages read local data from the very first request
|
||||
and only ever wait on Gitea when no snapshot exists yet.
|
||||
|
||||
Freshness contract, shown in the UI: Gitea-side data may lag
|
||||
by up to one interval; a Gitea webhook refreshes the touched
|
||||
repo immediately (see refresh_repo_snapshots); anything the
|
||||
user does locally (board journal) is always instant.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from app import models
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger("kanbante.snapshots")
|
||||
|
||||
_REFRESH_CONCURRENCY = 8
|
||||
|
||||
|
||||
async def refresh_repo_snapshots(gitea, owner: str, repo: str) -> None:
|
||||
"""Refresh one repo's hot keys (webhooks call this).
|
||||
|
||||
Both issue states are refreshed: most pages read "all", but
|
||||
Focus/Triage/Mes issues read "open" — a snapshot set missing
|
||||
either leaves a slow first hit on the table.
|
||||
"""
|
||||
try:
|
||||
await gitea.refresh_repo_issues(owner, repo, "all")
|
||||
await gitea.refresh_repo_issues(owner, repo, "open")
|
||||
await gitea.refresh_milestones(owner, repo)
|
||||
except Exception as exc:
|
||||
logger.debug("repo snapshot refresh failed for %s/%s: %s", owner, repo, exc)
|
||||
|
||||
|
||||
async def refresh_snapshots(gitea, classify_repos) -> int:
|
||||
"""One full refresh cycle. Returns the active-repo count."""
|
||||
repos = await gitea.refresh_search_repos()
|
||||
try:
|
||||
await gitea.refresh_starred()
|
||||
except Exception as exc:
|
||||
logger.debug("starred refresh failed: %s", exc)
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
semaphore = asyncio.Semaphore(_REFRESH_CONCURRENCY)
|
||||
|
||||
async def _one(repo: dict) -> None:
|
||||
owner, _, name = repo["full_name"].partition("/")
|
||||
async with semaphore:
|
||||
await refresh_repo_snapshots(gitea, owner, name)
|
||||
|
||||
await asyncio.gather(*(_one(repo) for repo in active))
|
||||
logger.info("snapshot refresh complete (%d active repos)", len(active))
|
||||
return len(active)
|
||||
|
||||
|
||||
async def snapshot_loop(gitea, classify_repos) -> None:
|
||||
"""Periodic refresher, started by the app lifespan.
|
||||
|
||||
Sleeps briefly first: hydration already serves the pages,
|
||||
the loop only has to keep the snapshots warm.
|
||||
"""
|
||||
interval = get_settings().snapshot_interval
|
||||
if interval <= 0:
|
||||
logger.info("snapshot refresher disabled (interval <= 0)")
|
||||
return
|
||||
await asyncio.sleep(10)
|
||||
while True:
|
||||
try:
|
||||
await refresh_snapshots(gitea, classify_repos)
|
||||
except Exception as exc:
|
||||
logger.warning("snapshot refresh cycle failed: %s", exc)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
def freshness_label() -> str | None:
|
||||
"""« à l'instant » / « il y a N min » from the freshest snapshot.
|
||||
|
||||
Honest freshness display (chantier B contract): the sidebar
|
||||
shows how old the Gitea data on screen can be.
|
||||
"""
|
||||
try:
|
||||
latest = models.latest_snapshot_at()
|
||||
except Exception:
|
||||
return None
|
||||
if not latest:
|
||||
return None
|
||||
try:
|
||||
fetched = datetime.datetime.strptime(
|
||||
latest, "%Y-%m-%d %H:%M:%S"
|
||||
).replace(tzinfo=datetime.timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
age = (
|
||||
datetime.datetime.now(datetime.timezone.utc) - fetched
|
||||
).total_seconds()
|
||||
if age < 90:
|
||||
return "à l'instant"
|
||||
if age < 3600:
|
||||
return f"il y a {int(age // 60)} min"
|
||||
if age < 86400:
|
||||
return f"il y a {int(age // 3600)} h"
|
||||
return f"il y a {int(age // 86400)} j"
|
||||
+38
-16
@@ -6,18 +6,6 @@
|
||||
Page active : `active_nav` (déduite du gabarit dans render()).
|
||||
Fil d'Ariane : `owner`+`repo` (board) ou `full_name` (projet).
|
||||
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
|
||||
<script>
|
||||
document.body.classList.add('with-sidebar');
|
||||
try {
|
||||
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
JSON.parse(localStorage.getItem('kanbante-nav-closed') || '[]')
|
||||
.forEach(function (k) {
|
||||
document.body.classList.add('nav-closed-' + k);
|
||||
});
|
||||
} catch (e) { /* stockage indisponible : état par défaut */ }
|
||||
</script>
|
||||
{% set nav_groups = [
|
||||
("principal", "Principal", [
|
||||
("home", "/", "🏠", "Projets"),
|
||||
@@ -42,6 +30,37 @@
|
||||
("wall", "/wall", "🖥️", "Wallboard"),
|
||||
]),
|
||||
] %}
|
||||
{% set active_group = namespace(key="principal") %}
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
{% for key, href, icon, label in links %}
|
||||
{% if active_nav == key %}{% set active_group.key = gkey %}{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
<script>
|
||||
document.body.classList.add('with-sidebar');
|
||||
try {
|
||||
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
/* Accordéon exclusif (0.22.0) : une seule section ouverte.
|
||||
La section de la page courante prime, sinon celle
|
||||
mémorisée, sinon « principal ». */
|
||||
localStorage.removeItem('kanbante-nav-closed');
|
||||
var openGroup = {{ active_group.key | tojson }};
|
||||
var stored = localStorage.getItem('kanbante-nav-open');
|
||||
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
|
||||
if ({{ (active_nav != "") | tojson }}) {
|
||||
localStorage.setItem('kanbante-nav-open', openGroup);
|
||||
} else if (stored === '') {
|
||||
openGroup = '';
|
||||
} else if (stored !== null && groups.indexOf(stored) >= 0) {
|
||||
openGroup = stored;
|
||||
}
|
||||
groups.forEach(function (k) {
|
||||
if (k !== openGroup) document.body.classList.add('nav-closed-' + k);
|
||||
});
|
||||
} catch (e) { /* stockage indisponible : état par défaut */ }
|
||||
</script>
|
||||
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
|
||||
<div class="side-head">
|
||||
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
|
||||
@@ -49,6 +68,7 @@
|
||||
<span class="side-label gradient-text">Kanbante</span>
|
||||
</a>
|
||||
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
|
||||
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
@@ -71,11 +91,13 @@
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<button type="button" class="side-collapse" id="side-collapse" aria-label="Replier ou déplier le menu">
|
||||
<span class="side-collapse-ico">«</span>
|
||||
<span class="side-label">Replier</span>
|
||||
</button>
|
||||
{% if data_freshness %}
|
||||
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
|
||||
{% endif %}
|
||||
<span class="side-version side-label">v{{ version }}</span>
|
||||
{% if auth_enabled %}
|
||||
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="side-backdrop" id="side-backdrop" hidden></div>
|
||||
|
||||
@@ -37,7 +37,12 @@
|
||||
<form method="post" action="/capture" class="capture-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Dépôt
|
||||
<input type="text" name="repo_full_name" value="{{ capture_repo }}" placeholder="Projets/kanbante" required>
|
||||
<span class="repo-picker">
|
||||
<input type="text" name="repo_full_name" id="capture-repo" value="{{ capture_repo }}" placeholder="Projets/kanbante" required autocomplete="off" role="combobox" aria-expanded="false" aria-controls="repo-listbox" aria-autocomplete="list">
|
||||
<span class="repo-list" id="repo-listbox" role="listbox" hidden></span>
|
||||
</span>
|
||||
<span class="repo-hint" id="repo-hint" hidden>⚠️ Aucun dépôt de ce nom dans Gitea — vérifie l'orthographe, la capture échouera sinon.</span>
|
||||
<span class="repo-tip">Astuce : tape un début, <kbd>Tab</kbd> complète segment par segment (<code>pr</code> → <code>Projets/</code> → <code>kan</code> → <code>kanbante</code>), ↑↓ + Entrée choisissent dans la liste.</span>
|
||||
</label>
|
||||
<label>Titre de l'issue
|
||||
<input type="text" name="title" maxlength="255" placeholder="Ce qu'il faut retenir…" required autofocus>
|
||||
@@ -84,6 +89,7 @@
|
||||
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/repo-picker.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -11,16 +11,21 @@
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<h2>Bonjour 👋 Voici vos projets Gitea, en un coup d'œil.</h2>
|
||||
<p>Glissez les cartes, suivez ce qui avance, et laissez Gitea rester la source de vérité.</p>
|
||||
</div>
|
||||
<div class="stat-tiles">
|
||||
<div class="stat-tile"><span class="stat-num">{{ active_repos|length }}</span><span class="stat-label">projets actifs</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ metrics.closed }}</span><span class="stat-label">issues fermées · 30 j</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ metrics.moved }}</span><span class="stat-label">déplacements · 30 j</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }} j{% else %}—{% endif %}</span><span class="stat-label">temps de cycle moyen</span></div>
|
||||
<!-- En-tête de page (refonte 0.20.0) : titre sobre, bande KPI
|
||||
unifiée, puis UNE barre d'outils (dans <main>) qui regroupe
|
||||
onglets, filtre, tri et vue — fini les rangées éparses. -->
|
||||
<section class="page-head">
|
||||
<div class="page-head-row">
|
||||
<div class="page-title">
|
||||
<h1>Projets</h1>
|
||||
<p class="page-sub">Vue d'ensemble de vos dépôts Gitea — Gitea reste la source de vérité.</p>
|
||||
</div>
|
||||
<div class="kpi-strip" aria-label="Indicateurs des 30 derniers jours">
|
||||
<div class="kpi"><span class="kpi-value">{{ active_repos|length }}</span><span class="kpi-label">Projets actifs</span></div>
|
||||
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.closed }}</span><span class="kpi-label">Issues fermées · 30 j</span></a>
|
||||
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.moved }}</span><span class="kpi-label">Déplacements · 30 j</span></a>
|
||||
<div class="kpi"><span class="kpi-value">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }}<span class="kpi-unit"> j</span>{% else %}—{% endif %}</span><span class="kpi-label">Temps de cycle moyen</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -40,31 +45,22 @@
|
||||
}
|
||||
</script>
|
||||
<main x-data="dashState()">
|
||||
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
|
||||
"archivé" = le vrai drapeau Gitea (repo.archived). -->
|
||||
<div class="tabs">
|
||||
<button :class="{ active: tab === 'active' }" @click="tab = 'active'">
|
||||
🟢 Actifs ({{ active_repos|length }})
|
||||
</button>
|
||||
<button :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">
|
||||
💤 Inactifs ({{ inactive_repos|length }})
|
||||
</button>
|
||||
<button :class="{ active: tab === 'archived' }" @click="tab = 'archived'">
|
||||
🗄️ Archivés ({{ archived_repos|length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search: the repo name lives in a data-* attribute (HTML-escaped
|
||||
by Jinja), never interpolated into a JS string literal. -->
|
||||
<div class="search-bar">
|
||||
<input type="text" id="project-search" placeholder="🔍 Filtrer les projets..." x-model="search" autocomplete="off">
|
||||
<kbd class="kbd">/</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Outils d'affichage (#21 tri/regroupement, #22 vue liste) :
|
||||
pilotés par dashboard.js, préférences en localStorage. -->
|
||||
<div class="dash-tools">
|
||||
<label class="dash-tool">↕️ Tri
|
||||
<!-- Barre d'outils unique (0.20.0) : onglets segmentés,
|
||||
filtre, tri/regroupement (#21) et vue (#22) alignés.
|
||||
Onglets : "actif" = fenêtre glissante ; "archivé" =
|
||||
le vrai drapeau Gitea (repo.archived). Le filtre lit
|
||||
des data-* échappés par Jinja, jamais des littéraux JS. -->
|
||||
<div class="dash-toolbar">
|
||||
<div class="seg-tabs" role="tablist" aria-label="État des projets">
|
||||
<button type="button" role="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'">Actifs <span class="seg-count">{{ active_repos|length }}</span></button>
|
||||
<button type="button" role="tab" :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">Inactifs <span class="seg-count">{{ inactive_repos|length }}</span></button>
|
||||
<button type="button" role="tab" :class="{ active: tab === 'archived' }" @click="tab = 'archived'">Archivés <span class="seg-count">{{ archived_repos|length }}</span></button>
|
||||
</div>
|
||||
<div class="search-bar toolbar-search">
|
||||
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
|
||||
<kbd class="kbd">/</kbd>
|
||||
</div>
|
||||
<label class="dash-tool">Tri
|
||||
<select id="dash-sort">
|
||||
<option value="default">Par défaut</option>
|
||||
<option value="updated">Activité Gitea récente</option>
|
||||
@@ -74,7 +70,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="dash-tool dash-tool-check">
|
||||
<input type="checkbox" id="dash-group"> 🗂️ Grouper par propriétaire
|
||||
<input type="checkbox" id="dash-group"> Grouper par propriétaire
|
||||
</label>
|
||||
<div class="dash-view-toggle" role="group" aria-label="Mode d'affichage">
|
||||
<button type="button" :class="{ active: view === 'grid' }" @click="setView('grid')">▦ Grille</button>
|
||||
@@ -201,7 +197,7 @@
|
||||
<th data-sort="inflight">En vol</th>
|
||||
<th>Prochain jalon</th>
|
||||
<th>Santé</th>
|
||||
<th data-sort="updated">Dernière activité</th>
|
||||
<th data-sort="updated" data-first-dir="desc">Dernière activité</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<th data-sort="due">Échéance</th>
|
||||
<th>Progression</th>
|
||||
<th>ETA</th>
|
||||
<th data-sort="gap">Écart</th>
|
||||
<th data-sort="gap" data-first-dir="desc">Écart</th>
|
||||
<th data-sort="throughput">Débit / sem.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<th data-sort="severity">Santé</th>
|
||||
<th data-sort="project">Projet</th>
|
||||
<th data-sort="open">Ouvertes</th>
|
||||
<th data-sort="zombies">🧟 Zombies</th>
|
||||
<th data-sort="zombies" data-first-dir="desc">🧟 Zombies</th>
|
||||
<th data-sort="slowest">🐌 Goulot</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Connexion — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<main class="login-wrap">
|
||||
<section class="login-card">
|
||||
<div class="login-brand">
|
||||
<span class="login-mark" aria-hidden="true">📋</span>
|
||||
<h1>Kanbante</h1>
|
||||
<p class="login-sub">Pilotage de projets, adossé à votre Gitea</p>
|
||||
</div>
|
||||
{% if error %}
|
||||
<p class="login-error" role="alert">⚠️ {{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/login" class="login-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<label>Nom d'utilisateur
|
||||
<input type="text" name="username" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label>Mot de passe
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit" class="login-submit">Se connecter</button>
|
||||
</form>
|
||||
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.19.0"
|
||||
version = "0.22.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+183
-50
@@ -150,17 +150,44 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
.btn-sm:hover { background: var(--surface-3); border-color: var(--border-strong); text-decoration: none; }
|
||||
.btn-sm:active { transform: scale(0.97); }
|
||||
|
||||
/* ── Hero (dashboard) ───────────────────────── */
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
padding: 30px 24px 6px;
|
||||
/* ── En-tête de page Projets (0.20.0) ─────────
|
||||
Titre sobre + bande KPI unifiée ; les contrôles vivent dans
|
||||
.dash-toolbar (plus bas). Les .stat-tile restent utilisés par
|
||||
le briefing quotidien. */
|
||||
.page-head { padding: 22px 24px 0; }
|
||||
.page-head-row {
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
gap: 20px; flex-wrap: wrap;
|
||||
}
|
||||
.page-title h1 {
|
||||
font-size: 1.55rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
line-height: 1.15; margin: 0;
|
||||
}
|
||||
.page-sub { color: var(--text-2); font-size: 0.92rem; margin-top: 5px; }
|
||||
.kpi-strip {
|
||||
display: flex; align-items: stretch;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kpi {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
padding: 9px 18px 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
.kpi + .kpi { border-left: 1px solid var(--border); }
|
||||
a.kpi { transition: background 0.15s; }
|
||||
a.kpi:hover { background: var(--surface-2); text-decoration: none; }
|
||||
.kpi-value {
|
||||
font-size: 1.22rem; font-weight: 700; letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums; line-height: 1.2;
|
||||
}
|
||||
.kpi-unit { font-size: 0.85rem; font-weight: 600; color: var(--text-2); }
|
||||
.kpi-label {
|
||||
font-size: 0.68rem; color: var(--text-2);
|
||||
text-transform: uppercase; letter-spacing: 0.55px; white-space: nowrap;
|
||||
}
|
||||
.hero-text h2 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 6px; }
|
||||
.hero-text p { color: var(--text-2); font-size: 0.95rem; max-width: 520px; }
|
||||
.stat-tiles { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.stat-tile {
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||
@@ -175,38 +202,13 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
.stat-num { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.stat-label { font-size: 0.72rem; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
|
||||
/* ── Tabs + search (dashboard) ──────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 18px 24px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tabs button.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.9), rgba(91,138,247,.9));
|
||||
border-color: transparent;
|
||||
}
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button.active:hover { color: #fff; }
|
||||
|
||||
/* ── Recherche (dashboard + filtre prévisions) ── */
|
||||
.search-bar { padding: 14px 24px 2px; display: flex; align-items: center; gap: 10px; }
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
padding: 11px 16px;
|
||||
border-radius: 999px;
|
||||
padding: 9px 13px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
@@ -869,13 +871,18 @@ footer {
|
||||
|
||||
/* ── Responsive ─────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.hero { flex-direction: column; align-items: flex-start; }
|
||||
.page-head-row { flex-direction: column; align-items: flex-start; }
|
||||
.kpi-strip { width: 100%; }
|
||||
.kpi { flex: 1 1 0; padding: 9px 12px 10px; }
|
||||
.kpi-label { white-space: normal; }
|
||||
.kanban-column { max-height: none; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-header, header { padding: 10px 14px; }
|
||||
.crumb { display: none; }
|
||||
.hero, .tabs, .search-bar, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
|
||||
.page-head, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
|
||||
.dash-toolbar { margin-left: 14px; margin-right: 14px; }
|
||||
.search-bar { padding-left: 14px; padding-right: 14px; }
|
||||
.milestone-banner { margin-left: 14px; margin-right: 14px; }
|
||||
.board-container { flex-direction: column; overflow: visible; }
|
||||
.notes-panel { width: 100%; max-height: 240px; border-left: none; border-top: 1px solid var(--border); }
|
||||
@@ -1290,11 +1297,22 @@ body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
}
|
||||
.side-collapse {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
background: transparent; border: 1px solid var(--border); border-radius: 9px;
|
||||
color: var(--text-2); padding: 6px 10px; font-size: 0.85rem; white-space: nowrap;
|
||||
/* Depuis la 0.22.0 : bouton icône seule dans l'en-tête de la
|
||||
barre, à droite de la recherche (même gabarit qu'elle). */
|
||||
flex-shrink: 0;
|
||||
width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--surface-2); color: var(--text-2); font-size: 1.02rem;
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.15s, color 0.15s, transform 0.15s;
|
||||
}
|
||||
.side-collapse:hover { color: var(--text); }
|
||||
.side-collapse:hover {
|
||||
color: var(--text); border-color: var(--accent, #58a6ff);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; }
|
||||
.side-logout { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
|
||||
.side-logout:hover { color: var(--text); text-decoration: none; }
|
||||
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
|
||||
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
|
||||
.side-version { color: var(--text-3); font-size: 0.8rem; }
|
||||
@@ -1374,20 +1392,51 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
|
||||
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
|
||||
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
|
||||
.dash-tools {
|
||||
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
|
||||
padding: 0 24px; margin: 4px 0 14px;
|
||||
/* Barre d'outils unique du dashboard (0.20.0) : onglets
|
||||
segmentés, filtre, tri, regroupement et vue dans UN cadre. */
|
||||
.dash-toolbar {
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
margin: 16px 24px 18px; padding: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); }
|
||||
.seg-tabs {
|
||||
display: inline-flex; gap: 2px; padding: 3px;
|
||||
background: var(--surface-2); border-radius: 9px;
|
||||
}
|
||||
.seg-tabs button {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 6px 13px; border: none; border-radius: 7px;
|
||||
background: transparent; color: var(--text-2);
|
||||
cursor: pointer; font-size: 0.88rem; font-weight: 500;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
min-height: 32px;
|
||||
}
|
||||
.seg-tabs button:hover { color: var(--text); }
|
||||
.seg-tabs button.active {
|
||||
background: var(--surface); color: var(--text); font-weight: 600;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.seg-count {
|
||||
font-size: 0.74rem; font-weight: 600; font-variant-numeric: tabular-nums;
|
||||
background: rgba(255, 255, 255, 0.08); color: var(--text-2);
|
||||
border-radius: 999px; padding: 1px 7px;
|
||||
}
|
||||
.seg-tabs button.active .seg-count { background: rgba(124, 92, 252, 0.22); color: var(--text); }
|
||||
.dash-toolbar .toolbar-search { padding: 0; flex: 1 1 200px; }
|
||||
.dash-toolbar .toolbar-search input { max-width: none; background: var(--surface-2); }
|
||||
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); white-space: nowrap; }
|
||||
.dash-tool select {
|
||||
background: var(--surface-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.dash-tool-check input { width: 17px; height: 17px; accent-color: #7c5cfc; }
|
||||
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
|
||||
.dash-view-toggle button {
|
||||
background: var(--surface-1); color: var(--text-2); border: none;
|
||||
padding: 7px 14px; cursor: pointer; font-size: 0.88rem;
|
||||
padding: 7px 14px; cursor: pointer; font-size: 0.88rem; min-height: 36px;
|
||||
}
|
||||
.dash-view-toggle button.active { background: var(--surface-3, #1d2942); color: var(--text); font-weight: 700; }
|
||||
.owner-group { grid-column: 1 / -1; }
|
||||
@@ -1475,3 +1524,87 @@ a.ms-title:hover { text-decoration: underline; }
|
||||
border: 1px solid var(--border, #333);
|
||||
}
|
||||
.health-back { font-size: 13.5px; }
|
||||
|
||||
/* ── Sélecteur de dépôt Capture (0.21.0) ────── */
|
||||
.repo-picker { position: relative; display: block; }
|
||||
.repo-list {
|
||||
position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 80;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface-1, #141a26);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 5px;
|
||||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.5);
|
||||
max-height: 264px; overflow-y: auto;
|
||||
}
|
||||
.repo-list[hidden] { display: none !important; }
|
||||
.repo-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 8px 10px; border-radius: 8px; cursor: pointer;
|
||||
font-weight: 400; font-size: 0.94rem;
|
||||
}
|
||||
.repo-item.active { background: var(--surface-2, #1c2330); }
|
||||
.repo-item-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.repo-item-meta { color: var(--text-3, #8b94a5); font-size: 0.78rem; white-space: nowrap; }
|
||||
.repo-hint { font-weight: 400; font-size: 0.85rem; color: #f0a35e; }
|
||||
.repo-tip { font-weight: 400; font-size: 0.82rem; color: var(--text-3, #8b94a5); }
|
||||
.repo-tip kbd {
|
||||
font-size: 0.72rem; border: 1px solid var(--border); border-bottom-width: 2px;
|
||||
border-radius: 5px; padding: 0 5px; background: var(--surface);
|
||||
}
|
||||
.capture-form input.repo-unknown { border-color: #f0a35e; }
|
||||
|
||||
/* ── Écran de connexion (0.22.0) ────────────── */
|
||||
.login-body {
|
||||
min-height: 100dvh; margin: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background:
|
||||
radial-gradient(640px 420px at 18% 12%, rgba(124, 92, 252, 0.20), transparent 62%),
|
||||
radial-gradient(560px 380px at 84% 88%, rgba(91, 138, 247, 0.16), transparent 60%),
|
||||
var(--bg, #0a0f16);
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.login-wrap { width: 100%; max-width: 400px; }
|
||||
.login-card {
|
||||
background: var(--surface, #10151f);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 34px 32px 26px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.login-brand { text-align: center; margin-bottom: 22px; }
|
||||
.login-mark {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 56px; height: 56px; border-radius: 16px; font-size: 1.7rem;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.35), rgba(91,138,247,.25));
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.login-brand h1 {
|
||||
margin: 12px 0 4px; font-size: 1.55rem; font-weight: 700;
|
||||
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
|
||||
-webkit-background-clip: text; background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.login-sub { margin: 0; color: var(--text-3, #8b94a5); font-size: 0.9rem; }
|
||||
.login-error {
|
||||
margin: 0 0 14px; padding: 10px 12px; border-radius: 10px;
|
||||
background: rgba(240, 120, 90, 0.12); border: 1px solid rgba(240, 120, 90, 0.45);
|
||||
color: #f5a58c; font-size: 0.9rem;
|
||||
}
|
||||
.login-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.login-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 0.92rem; }
|
||||
.login-form input {
|
||||
padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--bg-2, #0d1220); color: var(--text-1, #e6ebf2);
|
||||
font: inherit; font-weight: 400;
|
||||
}
|
||||
.login-form input:focus { outline: 2px solid rgba(124, 92, 252, 0.55); outline-offset: 0; border-color: transparent; }
|
||||
.login-submit {
|
||||
margin-top: 4px; padding: 12px 18px; cursor: pointer;
|
||||
background: linear-gradient(135deg, var(--accent, #7c5cfc), var(--accent-2, #5b8af7));
|
||||
color: #fff; border: none; border-radius: 10px;
|
||||
font: inherit; font-weight: 600; font-size: 1rem;
|
||||
transition: filter 0.15s, transform 0.1s;
|
||||
}
|
||||
.login-submit:hover { filter: brightness(1.12); }
|
||||
.login-submit:active { transform: scale(0.98); }
|
||||
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }
|
||||
|
||||
+15
-1
@@ -21,7 +21,11 @@
|
||||
|
||||
/* ── Tri des grilles de cartes ─────────────────────────── */
|
||||
var sortSelect = document.getElementById('dash-sort');
|
||||
var grids = ['grid-active', 'grid-inactive', 'grid-archived']
|
||||
// 0.20.1 : la grille des Épinglés est incluse — l'exclure
|
||||
// (choix de la 0.18.0) donnait l'impression que le tri et le
|
||||
// regroupement « ne faisaient rien », la section épinglée
|
||||
// étant celle qu'on regarde en premier.
|
||||
var grids = ['pinned-grid', 'grid-active', 'grid-inactive', 'grid-archived']
|
||||
.map(function (id) { return document.getElementById(id); })
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -38,6 +42,16 @@
|
||||
if (mode === 'default' || !cards.length) return;
|
||||
cards.sort(function (a, b) {
|
||||
var ka = cardKey(a, mode), kb = cardKey(b, mode);
|
||||
if (mode === 'updated' || mode === 'activity') {
|
||||
// Comparaison en instants réels (les fuseaux des
|
||||
// dates Gitea varient, -04:00/-05:00) ; une carte
|
||||
// sans date coule en bas du tri décroissant.
|
||||
var ta = Date.parse(ka), tb = Date.parse(kb);
|
||||
if (isNaN(ta) && isNaN(tb)) return 0;
|
||||
if (isNaN(ta)) return 1;
|
||||
if (isNaN(tb)) return -1;
|
||||
return tb - ta;
|
||||
}
|
||||
if (ka < kb) return mode === 'name' ? -1 : 1;
|
||||
if (ka > kb) return mode === 'name' ? 1 : -1;
|
||||
return 0;
|
||||
|
||||
+18
-31
@@ -43,46 +43,33 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* Accordion sections: the closed set lives in body classes
|
||||
(applied pre-paint by the bootstrap script in _header.html)
|
||||
and is persisted here on every toggle. */
|
||||
/* Accordéon EXCLUSIF (0.22.0) : une seule section ouverte
|
||||
à la fois. Ouvrir une section ferme les autres ; cliquer
|
||||
la section ouverte la referme. L'état initial vient du
|
||||
script d'amorce de _header.html (section de la page
|
||||
courante ou choix mémorisé dans kanbante-nav-open). */
|
||||
var groupBtns = document.querySelectorAll('[data-group-btn]');
|
||||
function persistClosedGroups() {
|
||||
var closed = [];
|
||||
groupBtns.forEach(function (btn) {
|
||||
if (document.body.classList.contains('nav-closed-' + btn.getAttribute('data-group-btn'))) {
|
||||
closed.push(btn.getAttribute('data-group-btn'));
|
||||
}
|
||||
});
|
||||
try {
|
||||
localStorage.setItem('kanbante-nav-closed', JSON.stringify(closed));
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
function syncGroupBtn(btn) {
|
||||
var closed = document.body.classList.contains(
|
||||
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||
btn.setAttribute('aria-expanded', closed ? 'false' : 'true');
|
||||
}
|
||||
function setOpenGroup(openKey) {
|
||||
groupBtns.forEach(function (btn) {
|
||||
var key = btn.getAttribute('data-group-btn');
|
||||
document.body.classList.toggle('nav-closed-' + key, key !== openKey);
|
||||
syncGroupBtn(btn);
|
||||
});
|
||||
try {
|
||||
localStorage.setItem('kanbante-nav-open', openKey);
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
groupBtns.forEach(function (btn) {
|
||||
syncGroupBtn(btn);
|
||||
btn.addEventListener('click', function () {
|
||||
document.body.classList.toggle(
|
||||
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||
syncGroupBtn(btn);
|
||||
persistClosedGroups();
|
||||
var key = btn.getAttribute('data-group-btn');
|
||||
var isOpen = !document.body.classList.contains('nav-closed-' + key);
|
||||
setOpenGroup(isOpen ? '' : key);
|
||||
});
|
||||
});
|
||||
/* The section holding the current page is always opened. */
|
||||
var activeLink = document.querySelector('.sidebar .side-link.active');
|
||||
if (activeLink) {
|
||||
var section = activeLink.closest('.side-section');
|
||||
if (section) {
|
||||
var key = section.getAttribute('data-group');
|
||||
if (document.body.classList.contains('nav-closed-' + key)) {
|
||||
document.body.classList.remove('nav-closed-' + key);
|
||||
groupBtns.forEach(syncGroupBtn);
|
||||
persistClosedGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Kanbante — sélecteur de dépôt de la Capture éclair (0.21.0).
|
||||
*
|
||||
* Le champ Dépôt devient intelligent :
|
||||
* - suggestions des dépôts réels dès la frappe (données /api/repos,
|
||||
* épinglés et récents d'abord) ;
|
||||
* - correspondance sur les deux segments : « kan » suffit pour
|
||||
* trouver Projets/kanbante, « pr/kan » aussi ;
|
||||
* - complétion Tab segment par segment, comme un shell :
|
||||
* « pr » Tab → « Projets/ », puis « kan » Tab → « kanbante » ;
|
||||
* - ↑/↓ + Entrée, clic souris, Échap ;
|
||||
* - avertissement doux si le nom final n'existe pas dans Gitea.
|
||||
*/
|
||||
(function () {
|
||||
var input = document.getElementById('capture-repo');
|
||||
if (!input) return;
|
||||
var list = document.getElementById('repo-listbox');
|
||||
var hint = document.getElementById('repo-hint');
|
||||
var repos = null;
|
||||
var candidates = [];
|
||||
var activeIndex = -1;
|
||||
var isOpen = false;
|
||||
|
||||
function load() {
|
||||
if (repos) return Promise.resolve(repos);
|
||||
return fetch('/api/repos', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) { repos = data.repos || []; return repos; })
|
||||
.catch(function () { repos = []; return repos; });
|
||||
}
|
||||
|
||||
/* ── Correspondance ──────────────────────────────────── */
|
||||
function matchRepos(value) {
|
||||
var raw = value.trim().toLowerCase();
|
||||
if (!raw) return repos.slice(0, 12);
|
||||
var parts = raw.split('/').map(function (s) { return s.trim(); });
|
||||
var scored = [];
|
||||
repos.forEach(function (repo) {
|
||||
var full = repo.full_name.toLowerCase();
|
||||
var slash = full.indexOf('/');
|
||||
var owner = full.slice(0, slash);
|
||||
var name = full.slice(slash + 1);
|
||||
var score = null;
|
||||
if (parts.length === 1) {
|
||||
var q = parts[0];
|
||||
if (!q) score = 4;
|
||||
else if (name.indexOf(q) === 0) score = 0;
|
||||
else if (owner.indexOf(q) === 0) score = 1;
|
||||
else if (name.indexOf(q) > -1) score = 2;
|
||||
else if (full.indexOf(q) > -1) score = 3;
|
||||
} else {
|
||||
if (owner.indexOf(parts[0]) === 0 && name.indexOf(parts[1]) === 0) score = 0;
|
||||
else if (owner.indexOf(parts[0]) > -1 && name.indexOf(parts[1]) > -1) score = 1;
|
||||
}
|
||||
if (score !== null) scored.push({ repo: repo, score: score });
|
||||
});
|
||||
scored.sort(function (a, b) {
|
||||
if (a.score !== b.score) return a.score - b.score;
|
||||
if (a.repo.starred !== b.repo.starred) return a.repo.starred ? -1 : 1;
|
||||
return (b.repo.updated_at || '').localeCompare(a.repo.updated_at || '');
|
||||
});
|
||||
return scored.slice(0, 12).map(function (s) { return s.repo; });
|
||||
}
|
||||
|
||||
/* ── Rendu / ouverture ───────────────────────────────── */
|
||||
function highlight() {
|
||||
Array.prototype.forEach.call(list.children, function (item, i) {
|
||||
item.classList.toggle('active', i === activeIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
list.innerHTML = '';
|
||||
candidates.forEach(function (repo, i) {
|
||||
var item = document.createElement('span');
|
||||
item.className = 'repo-item';
|
||||
item.setAttribute('role', 'option');
|
||||
var star = repo.starred ? '⭐ ' : '';
|
||||
var date = repo.updated_at ? repo.updated_at.slice(0, 10) : '';
|
||||
var nameEl = document.createElement('span');
|
||||
nameEl.className = 'repo-item-name';
|
||||
nameEl.textContent = star + repo.full_name;
|
||||
var metaEl = document.createElement('span');
|
||||
metaEl.className = 'repo-item-meta';
|
||||
metaEl.textContent = (repo.archived ? 'archivé · ' : '') + date;
|
||||
item.appendChild(nameEl);
|
||||
item.appendChild(metaEl);
|
||||
item.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
choose(repo);
|
||||
});
|
||||
item.addEventListener('mousemove', function () {
|
||||
if (activeIndex !== i) { activeIndex = i; highlight(); }
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
highlight();
|
||||
list.hidden = !isOpen || candidates.length === 0;
|
||||
input.setAttribute('aria-expanded', isOpen && candidates.length ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function refresh(openList) {
|
||||
if (!repos) return;
|
||||
candidates = matchRepos(input.value);
|
||||
if (activeIndex >= candidates.length) activeIndex = candidates.length - 1;
|
||||
isOpen = openList;
|
||||
render();
|
||||
validate();
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
render();
|
||||
}
|
||||
|
||||
function choose(repo) {
|
||||
input.value = repo.full_name;
|
||||
close();
|
||||
validate();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function validate() {
|
||||
if (!hint || !repos) return;
|
||||
var value = input.value.trim().toLowerCase();
|
||||
var exact = !value || repos.some(function (r) {
|
||||
return r.full_name.toLowerCase() === value;
|
||||
});
|
||||
hint.hidden = exact;
|
||||
input.classList.toggle('repo-unknown', !exact);
|
||||
}
|
||||
|
||||
/* ── Complétion Tab, segment par segment ─────────────── */
|
||||
function commonPrefix(strings) {
|
||||
if (!strings.length) return '';
|
||||
var prefix = strings[0];
|
||||
for (var i = 1; i < strings.length; i++) {
|
||||
while (strings[i].indexOf(prefix) !== 0) {
|
||||
prefix = prefix.slice(0, -1);
|
||||
if (!prefix) return '';
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function completeSegment() {
|
||||
if (!repos) return false;
|
||||
var value = input.value;
|
||||
var slash = value.lastIndexOf('/');
|
||||
var head = slash >= 0 ? value.slice(0, slash + 1) : '';
|
||||
var segment = slash >= 0 ? value.slice(slash + 1) : value;
|
||||
var pool = [];
|
||||
if (slash < 0) {
|
||||
var owners = {};
|
||||
repos.forEach(function (r) {
|
||||
var o = r.full_name.split('/')[0];
|
||||
if (o.toLowerCase().indexOf(segment.toLowerCase()) === 0) owners[o] = true;
|
||||
});
|
||||
pool = Object.keys(owners);
|
||||
} else {
|
||||
var ownerPart = value.slice(0, slash).toLowerCase();
|
||||
repos.forEach(function (r) {
|
||||
var parts = r.full_name.split('/');
|
||||
if (parts[0].toLowerCase() === ownerPart &&
|
||||
parts[1].toLowerCase().indexOf(segment.toLowerCase()) === 0) {
|
||||
pool.push(parts[1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!pool.length) return false;
|
||||
var lcp = commonPrefix(pool);
|
||||
if (lcp.length > segment.length &&
|
||||
lcp.toLowerCase().indexOf(segment.toLowerCase()) === 0) {
|
||||
var completed = head + lcp;
|
||||
// La complétion tombe sur un nom d'organisation exact :
|
||||
// poser le séparateur dans le même Tab (comme un shell
|
||||
// qui complète un répertoire) — le geste « pr » Tab
|
||||
// donne « Projets/ », prêt pour le segment du dépôt.
|
||||
if (slash < 0 && pool.indexOf(lcp) !== -1) completed += '/';
|
||||
input.value = completed;
|
||||
refresh(true);
|
||||
return true;
|
||||
}
|
||||
// Segment d'organisation déjà complet et sans ambiguïté :
|
||||
// poser le séparateur et passer au segment du dépôt.
|
||||
if (slash < 0 && pool.length === 1 &&
|
||||
lcp.toLowerCase() === segment.toLowerCase()) {
|
||||
input.value = head + lcp + '/';
|
||||
refresh(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ── Événements ──────────────────────────────────────── */
|
||||
input.addEventListener('focus', function () {
|
||||
load().then(function () { refresh(true); });
|
||||
});
|
||||
input.addEventListener('input', function () {
|
||||
activeIndex = -1;
|
||||
load().then(function () { refresh(true); });
|
||||
});
|
||||
input.addEventListener('blur', function () {
|
||||
close();
|
||||
validate();
|
||||
});
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Tab') {
|
||||
if (completeSegment()) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
load().then(function () {
|
||||
if (!isOpen) { refresh(true); activeIndex = 0; highlight(); return; }
|
||||
var delta = e.key === 'ArrowDown' ? 1 : -1;
|
||||
activeIndex = Math.max(0, Math.min(candidates.length - 1, activeIndex + delta));
|
||||
highlight();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (isOpen && activeIndex >= 0 && candidates[activeIndex]) {
|
||||
e.preventDefault();
|
||||
choose(candidates[activeIndex]);
|
||||
} else if (isOpen && candidates.length === 1) {
|
||||
e.preventDefault();
|
||||
choose(candidates[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Si le champ est pré-rempli (lien ?repo= ou défaut), valider
|
||||
// dès que la liste est connue.
|
||||
if (input.value) load().then(validate);
|
||||
})();
|
||||
+14
-4
@@ -13,7 +13,12 @@
|
||||
th.classList.add('sortable');
|
||||
th.addEventListener('click', function () {
|
||||
var key = th.getAttribute('data-sort');
|
||||
var dir = th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
|
||||
// Premier clic : sens par défaut de la colonne
|
||||
// (data-first-dir="desc" pour les dates d'activité —
|
||||
// on veut le plus récent d'abord) ; ensuite on alterne.
|
||||
var dir = th.getAttribute('data-dir')
|
||||
? (th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc')
|
||||
: (th.getAttribute('data-first-dir') || 'asc');
|
||||
table.querySelectorAll('th').forEach(function (h) {
|
||||
h.removeAttribute('data-dir');
|
||||
});
|
||||
@@ -22,10 +27,15 @@
|
||||
rows.sort(function (a, b) {
|
||||
var va = a.getAttribute('data-' + key) || '';
|
||||
var vb = b.getAttribute('data-' + key) || '';
|
||||
var na = parseFloat(va), nb = parseFloat(vb);
|
||||
var cmp;
|
||||
if (!isNaN(na) && !isNaN(nb) && va !== '' && vb !== '') {
|
||||
cmp = na - nb;
|
||||
// Numérique SEULEMENT si la valeur entière est
|
||||
// un nombre. L'ancien parseFloat lisait « 2026 »
|
||||
// dans les dates ISO : toutes les lignes d'une
|
||||
// même année se retrouvaient à égalité et le tri
|
||||
// par date partait dans un ordre arbitraire
|
||||
// (bug rapporté par Bruno, corrigé en 0.20.1).
|
||||
if (/^-?\d+(\.\d+)?$/.test(va) && /^-?\d+(\.\d+)?$/.test(vb)) {
|
||||
cmp = parseFloat(va) - parseFloat(vb);
|
||||
} else {
|
||||
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -271,6 +271,26 @@ class FakeGitea:
|
||||
},
|
||||
]
|
||||
|
||||
# ── Snapshots (chantier B, 0.22.0) : le faux reflète
|
||||
# l'interface du vrai client.
|
||||
def hydrate_from_snapshots(self):
|
||||
return 0
|
||||
|
||||
def latest_snapshot_at(self):
|
||||
return None
|
||||
|
||||
async def refresh_search_repos(self):
|
||||
return await self.search_repos()
|
||||
|
||||
async def refresh_starred(self):
|
||||
return await self.list_starred()
|
||||
|
||||
async def refresh_repo_issues(self, owner, repo, state="all"):
|
||||
return await self.get_repo_issues(owner, repo, state=state)
|
||||
|
||||
async def refresh_milestones(self, owner, repo):
|
||||
return await self.get_milestones(owner, repo)
|
||||
|
||||
async def list_starred(self):
|
||||
return [r for r in self.repos if r["full_name"] in self.starred]
|
||||
|
||||
|
||||
@@ -20,9 +20,17 @@ def test_auth_required_when_configured(monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as client:
|
||||
# Sans en-tête navigateur : 401 (comportement historique).
|
||||
assert client.get("/").status_code == 401
|
||||
# Le Basic reste accepté pour les scripts (0.22.0).
|
||||
assert client.get("/", headers=_basic("bruno", "mauvais")).status_code == 401
|
||||
assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200
|
||||
# Un navigateur, lui, est envoyé vers l'écran de connexion.
|
||||
r = client.get(
|
||||
"/", headers={"Accept": "text/html"}, follow_redirects=False
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/login")
|
||||
# Liveness stays public for Docker HEALTHCHECK.
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@ def test_nav_js_contract(client, fake_gitea):
|
||||
assert "localStorage" in js.text
|
||||
assert "side-backdrop" in js.text
|
||||
assert "Escape" in js.text
|
||||
assert "kanbante-nav-closed" in js.text # accordéons persistés
|
||||
assert "kanbante-nav-open" in js.text # accordéon exclusif persisté (0.22.0)
|
||||
assert "setOpenGroup" in js.text
|
||||
assert "data-group-btn" in js.text
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Palier 16 (0.20.0) — refonte professionnelle de l'entête Projets.
|
||||
|
||||
Fini le « Bonjour 👋 » et les rangées éparses : titre sobre,
|
||||
bande KPI unifiée, et une seule barre d'outils (onglets
|
||||
segmentés, filtre, tri, regroupement, vue). Les crochets
|
||||
fonctionnels (ids, liaisons Alpine) sont inchangés.
|
||||
"""
|
||||
|
||||
|
||||
def test_dashboard_professional_header(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
# Titre sobre + sous-titre, plus de formule familière.
|
||||
assert "<h1>Projets</h1>" in r.text
|
||||
assert "Bonjour" not in r.text
|
||||
# Bande KPI unifiée : valeurs + libellés.
|
||||
assert "kpi-strip" in r.text
|
||||
assert "Projets actifs" in r.text
|
||||
assert "Issues fermées · 30 j" in r.text
|
||||
assert "Temps de cycle moyen" in r.text
|
||||
# Les KPI journal mènent à l'activité.
|
||||
assert 'href="/activity"' in r.text
|
||||
|
||||
|
||||
def test_dashboard_single_toolbar_keeps_its_hooks(client):
|
||||
r = client.get("/")
|
||||
assert "dash-toolbar" in r.text
|
||||
# Onglets segmentés avec compteurs, mêmes liaisons Alpine.
|
||||
assert "seg-tabs" in r.text
|
||||
assert "seg-count" in r.text
|
||||
assert "tab = 'inactive'" in r.text
|
||||
# Filtre, tri, regroupement et vue : ids préservés pour
|
||||
# dashboard.js et l'état Alpine.
|
||||
for hook in ('id="project-search"', 'id="dash-sort"',
|
||||
'id="dash-group"', "setView('list')"):
|
||||
assert hook in r.text, hook
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Palier 17 (0.21.0) — sélecteur de dépôt intelligent (Capture).
|
||||
|
||||
- GET /api/repos : liste exhaustive des dépôts pour les
|
||||
sélecteurs (la palette reste plafonnée à 60), épinglés
|
||||
d'abord, archivés en dernier.
|
||||
- La page /capture branche le sélecteur sur le champ Dépôt
|
||||
(liste filtrée + complétion Tab par segment, repo-picker.js).
|
||||
"""
|
||||
|
||||
|
||||
def test_api_repos_lists_everything_ranked(client, fake_gitea):
|
||||
fake_gitea.starred.add("Projets/vieux")
|
||||
r = client.get("/api/repos")
|
||||
assert r.status_code == 200
|
||||
repos = r.json()["repos"]
|
||||
names = [x["full_name"] for x in repos]
|
||||
# Exhaustif : actifs + inactifs + archivés du faux Gitea.
|
||||
assert names == ["Projets/vieux", "Projets/demo", "Projets/archive"] or set(names) == {
|
||||
"Projets/demo", "Projets/vieux", "Projets/archive",
|
||||
}
|
||||
# L'épinglé passe devant malgré son inactivité.
|
||||
assert names[0] == "Projets/vieux"
|
||||
by_name = {x["full_name"]: x for x in repos}
|
||||
assert by_name["Projets/vieux"]["starred"] is True
|
||||
assert by_name["Projets/demo"]["starred"] is False
|
||||
assert by_name["Projets/archive"]["archived"] is True
|
||||
|
||||
|
||||
def test_capture_page_wires_the_repo_picker(client):
|
||||
r = client.get("/capture")
|
||||
assert r.status_code == 200
|
||||
assert 'id="capture-repo"' in r.text
|
||||
assert 'id="repo-listbox"' in r.text
|
||||
assert "repo-picker.js" in r.text
|
||||
# Le contrat du formulaire ne change pas.
|
||||
assert 'name="repo_full_name"' in r.text
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Palier 18 (0.22.0) — login screen, sidebar, chantier B.
|
||||
|
||||
- Connexion par formulaire + cookie de session signé (le Basic
|
||||
reste accepté pour les scripts) ; les navigateurs sont
|
||||
redirigés vers /login, les API reçoivent 401.
|
||||
- Bouton replier dans l'en-tête de la barre latérale (icône
|
||||
seule, à droite de la recherche) ; accordéon exclusif.
|
||||
- Instantanés : persistance des réponses du client, hydratation
|
||||
au démarrage, étiquette de fraîcheur.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import models
|
||||
from app.gitea_client import GiteaClient
|
||||
from app.snapshots import freshness_label
|
||||
|
||||
|
||||
def _auth_client(monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
return TestClient(main.app)
|
||||
|
||||
|
||||
def _csrf_from(html: str) -> str:
|
||||
m = re.search(r'name="csrf_token" value="([^"]+)"', html)
|
||||
assert m, "le formulaire de connexion doit porter le jeton CSRF"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_browser_is_redirected_to_login(monkeypatch, fake_gitea):
|
||||
with _auth_client(monkeypatch, fake_gitea) as client:
|
||||
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/login")
|
||||
# Les API, elles, reçoivent un 401 sec (pas de redirection).
|
||||
r = client.get("/api/repos")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_login_flow_and_logout(monkeypatch, fake_gitea):
|
||||
with _auth_client(monkeypatch, fake_gitea) as client:
|
||||
page = client.get("/login")
|
||||
assert page.status_code == 200
|
||||
assert "login-card" in page.text
|
||||
token = _csrf_from(page.text)
|
||||
|
||||
bad = client.post(
|
||||
"/login",
|
||||
data={"username": "bruno", "password": "non", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert bad.status_code == 401
|
||||
assert "Identifiants incorrects" in bad.text
|
||||
|
||||
ok = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "bruno",
|
||||
"password": "motdepasse",
|
||||
"csrf_token": token,
|
||||
"next": "/focus",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert ok.status_code == 303
|
||||
assert ok.headers["location"] == "/focus"
|
||||
# La session ouvre les pages sans autre en-tête.
|
||||
assert client.get("/").status_code == 200
|
||||
|
||||
client.get("/logout")
|
||||
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
|
||||
def test_login_next_is_local_only(monkeypatch, fake_gitea):
|
||||
with _auth_client(monkeypatch, fake_gitea) as client:
|
||||
token = _csrf_from(client.get("/login").text)
|
||||
r = client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "bruno",
|
||||
"password": "motdepasse",
|
||||
"csrf_token": token,
|
||||
"next": "https://evil.example/phish",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == "/"
|
||||
|
||||
|
||||
def test_sidebar_head_and_exclusive_accordion(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
head = r.text.split('class="side-nav"')[0]
|
||||
assert 'id="side-collapse"' in head # replier dans l'en-tête…
|
||||
assert head.index("side-search") < head.index('id="side-collapse"') # …à droite de la recherche
|
||||
assert '<span class="side-label">Replier</span>' not in r.text # icône seule
|
||||
assert "kanbante-nav-open" in r.text # accordéon exclusif (amorce)
|
||||
|
||||
|
||||
async def test_snapshot_write_through_and_hydration():
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"full_name": "Projets/demo", "archived": False}]},
|
||||
headers={"X-Total-Count": "1"},
|
||||
)
|
||||
|
||||
writer = GiteaClient(
|
||||
base_url="http://gitea.test", cache_ttl=30,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
repos = await writer.search_repos()
|
||||
assert repos and repos[0]["full_name"] == "Projets/demo"
|
||||
stored = models.load_snapshots()
|
||||
assert "search_repos" in stored
|
||||
|
||||
def boom(request):
|
||||
raise AssertionError("Gitea ne doit pas être appelé : l'instantané suffit")
|
||||
|
||||
reader = GiteaClient(
|
||||
base_url="http://gitea.test", cache_ttl=30,
|
||||
transport=httpx.MockTransport(boom),
|
||||
)
|
||||
assert reader.hydrate_from_snapshots() >= 1
|
||||
served = await reader.search_repos()
|
||||
assert served[0]["full_name"] == "Projets/demo"
|
||||
assert freshness_label() == "à l'instant"
|
||||
@@ -198,5 +198,7 @@ def test_dashboard_uses_new_design_system(client):
|
||||
r = client.get("/")
|
||||
assert "app-header" in r.text
|
||||
assert "nav-pill" in r.text
|
||||
assert "stat-tile" in r.text
|
||||
# En-tête refondu en 0.20.0 : bande KPI unifiée (les stat-tile
|
||||
# ne servent plus qu'au briefing).
|
||||
assert "kpi-strip" in r.text
|
||||
assert "live.js" in r.text
|
||||
|
||||
Reference in New Issue
Block a user