Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2096c9b6 | ||
|
|
e63fe39578 | ||
|
|
b7d21f80df | ||
|
|
c7711c0ca2 | ||
|
|
319619d200 | ||
|
|
0bdde28b79 | ||
|
|
4b38acdcba | ||
|
|
760fbd8c6d | ||
|
|
f5d2d7d716 | ||
|
|
71e705ea64 | ||
|
|
7918b54943 | ||
|
|
3695bfac8a | ||
|
|
57d93c4f4b | ||
|
|
900590efed | ||
|
|
0aa991047f | ||
|
|
79122c20d0 | ||
|
|
3e3187d597 | ||
|
|
593f076555 | ||
|
|
311a14090d | ||
|
|
c67a14da65 | ||
|
|
7ec34245c5 |
+178
@@ -1,5 +1,183 @@
|
||||
# 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)
|
||||
|
||||
- **Parallélisation (A1)** : les pages inter-projets (dashboard,
|
||||
/health, /forecast, /triage, /wall) attendaient chaque dépôt à
|
||||
la queue leu leu ; elles utilisent maintenant un `asyncio.gather`
|
||||
borné (16) — le patron que /focus appliquait déjà.
|
||||
- **Cache stale-while-revalidate (A2)** : les listes Gitea
|
||||
(dépôts, issues, jalons, étoiles) sont servies immédiatement
|
||||
même périmées, et rafraîchies en tâche de fond. Fini le
|
||||
« mur » de rechargement à chaque expiration du cache (30 s) :
|
||||
seul le tout premier chargement après un démarrage paie le
|
||||
coût complet. Les mutations vident le cache et désarment les
|
||||
rafraîchissements en vol (aucune donnée périmée ressuscitée).
|
||||
- **Prévisions refondues (C1)** : /forecast devient un tableau de
|
||||
bord des jalons **tous projets** — une ligne par jalon (projet,
|
||||
échéance, progression, ETA, **écart**, débit), triée par risque,
|
||||
filtrable, colonnes triables ; le bloc jalon des cartes du
|
||||
dashboard y pointe directement (ancres). Le débit par projet
|
||||
reste en dessous, en tableau compact.
|
||||
- **Santé refondue (C2)** : /health devient un tableau trié par
|
||||
sévérité (pastille, ouvertes, zombies, goulot) ; la page santé
|
||||
d'un projet gagne un **sélecteur de projet** pour basculer sans
|
||||
repasser par l'index. Le tri des tableaux est mutualisé
|
||||
(`static/js/tablesort.js`, dashboard inclus).
|
||||
- Mesuré sur l'instance réelle (depuis le réseau de test) :
|
||||
pages chaudes 0,04–0,16 s ; le premier chargement à froid
|
||||
reste de quelques secondes — le chantier B (instantanés
|
||||
d'arrière-plan) demeure le levier pour le supprimer, si voulu.
|
||||
|
||||
## 0.18.0 — 2026-10-11
|
||||
|
||||
### Nouveautés — vague 2, suite (4 cartes de Bruno)
|
||||
- **Activité récente par projet** (#19) : ligne sur la carte du
|
||||
dashboard calculée par UNE requête agrégée sur le journal —
|
||||
« 📈 X fermées · Y déplacements cette semaine », ou « 💤 aucune
|
||||
activité kanban depuis N j » pour les projets endormis.
|
||||
- **Actions rapides sur la carte** (#23) : menu ⋯ sur chaque
|
||||
carte — Board, ⚡ Capturer ici (la page capture accepte
|
||||
`?repo=` et pré-remplit le dépôt), Triage du backlog, Santé,
|
||||
Récit de sprint, Gitea. Clavier et tactile soignés (Échap,
|
||||
clic extérieur).
|
||||
- **Tri et regroupement** (#21) : tri par activité Gitea,
|
||||
activité kanban, ouvertes ou nom ; regroupement par
|
||||
propriétaire en sections repliables ; préférences mémorisées
|
||||
par navigateur (localStorage).
|
||||
- **Vue liste dense** (#22) : bascule Grille/Liste persistée —
|
||||
tableau triable par colonne (Projet, Propriétaire, Ouvertes,
|
||||
En vol, Prochain jalon, Santé, Dernière activité) réutilisant
|
||||
les enrichissements des cartes, mêmes onglets et même filtre.
|
||||
|
||||
Note : le lien Triage du menu ⋯ est constant — la taille du
|
||||
backlog « stats » du dashboard ne compte que les cartes placées,
|
||||
pas le backlog réel ; la page Triage affiche l'état exact.
|
||||
|
||||
7 tests ajoutés (185 au total).
|
||||
|
||||
## 0.17.0 — 2026-10-11
|
||||
|
||||
### Nouveautés — vague 2 « Gestion de projets » (4 cartes de 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.17.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.17.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.22.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+165
-39
@@ -17,8 +17,10 @@ Changes from the 0.1.0 client:
|
||||
- .env loading targets the project root only (no hardcoded home path).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -60,6 +62,12 @@ class GiteaClient:
|
||||
)
|
||||
self._user: dict | None = None
|
||||
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()
|
||||
@@ -105,24 +113,146 @@ class GiteaClient:
|
||||
page += 1
|
||||
return items
|
||||
|
||||
def _cache_get(self, key: str) -> Any | None:
|
||||
if self.cache_ttl <= 0:
|
||||
return None
|
||||
hit = self._cache.get(key)
|
||||
if hit and (time.monotonic() - hit[0]) < self.cache_ttl:
|
||||
return hit[1]
|
||||
return None
|
||||
|
||||
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()
|
||||
# Invalidate in-flight background refreshes: a refresh that
|
||||
# started before a mutation must not resurrect stale data.
|
||||
self._cache_gen += 1
|
||||
|
||||
async def _cached_list(
|
||||
self, key: str, fetcher: Callable[[], Awaitable[Any]]
|
||||
) -> Any:
|
||||
"""Stale-while-revalidate cache for list endpoints (0.19.0).
|
||||
|
||||
Fresh (< TTL): serve the cache. Stale: serve the cached
|
||||
value IMMEDIATELY and refresh it in the background (once
|
||||
per key) — the user never waits on Gitea for a page when
|
||||
any earlier answer exists. Absent: fetch and wait.
|
||||
Mutations clear the cache (see _cache_clear), so writes are
|
||||
never hidden by a stale entry.
|
||||
"""
|
||||
if self.cache_ttl <= 0:
|
||||
return await fetcher()
|
||||
hit = self._cache.get(key)
|
||||
if hit is not None:
|
||||
if (time.monotonic() - hit[0]) < self.cache_ttl:
|
||||
return hit[1]
|
||||
if key not in self._refreshing:
|
||||
self._refreshing.add(key)
|
||||
asyncio.create_task(
|
||||
self._refresh_entry(key, fetcher, self._cache_gen)
|
||||
)
|
||||
return hit[1]
|
||||
value = await fetcher()
|
||||
self._cache_put(key, value)
|
||||
return value
|
||||
|
||||
async def _refresh_entry(
|
||||
self,
|
||||
key: str,
|
||||
fetcher: Callable[[], Awaitable[Any]],
|
||||
generation: int,
|
||||
) -> None:
|
||||
try:
|
||||
value = await fetcher()
|
||||
if generation == self._cache_gen:
|
||||
self._cache_put(key, value)
|
||||
except Exception as exc:
|
||||
logger.debug("background refresh failed for %s: %s", key, exc)
|
||||
finally:
|
||||
self._refreshing.discard(key)
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Drop every cached list response (called on webhook events)."""
|
||||
self._cache.clear()
|
||||
self._cache_clear()
|
||||
|
||||
# ── API ────────────────────────────────────────────────
|
||||
|
||||
@@ -140,21 +270,25 @@ class GiteaClient:
|
||||
self._user = await self._get("/user")
|
||||
return self._user
|
||||
|
||||
async def search_repos(self) -> list[dict]:
|
||||
"""All repos visible to the token owner (paginated, cached)."""
|
||||
cached = self._cache_get("search_repos")
|
||||
if cached is not None:
|
||||
return cached
|
||||
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)
|
||||
repos = await self._get_all_pages("/repos/search", **params)
|
||||
self._cache_put("search_repos", repos)
|
||||
return repos
|
||||
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 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}")
|
||||
@@ -178,14 +312,12 @@ class GiteaClient:
|
||||
(see app.main._is_issue).
|
||||
"""
|
||||
key = f"issues:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
issues = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
return await self._cached_list(
|
||||
key,
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
),
|
||||
)
|
||||
self._cache_put(key, issues)
|
||||
return issues
|
||||
|
||||
async def get_all_issues(
|
||||
self, repos: list[dict], state: str = "open"
|
||||
@@ -308,25 +440,19 @@ class GiteaClient:
|
||||
self, owner: str, repo: str, state: str = "open"
|
||||
) -> list[dict]:
|
||||
key = f"milestones:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
milestones = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
return await self._cached_list(
|
||||
key,
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
),
|
||||
)
|
||||
self._cache_put(key, milestones)
|
||||
return milestones
|
||||
|
||||
async def list_starred(self) -> list[dict]:
|
||||
"""Repos starred by the token user (issue #20: pinning rides
|
||||
on Gitea stars — the forge stays the source of truth)."""
|
||||
key = "user:starred"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
repos = await self._get_all_pages("/user/starred")
|
||||
self._cache_put(key, repos)
|
||||
return repos
|
||||
return await self._cached_list(
|
||||
"user:starred", lambda: self._get_all_pages("/user/starred")
|
||||
)
|
||||
|
||||
async def set_starred(self, owner: str, repo: str, starred: bool) -> None:
|
||||
"""Star / unstar a repo for the token user."""
|
||||
|
||||
+403
-101
@@ -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
|
||||
@@ -58,6 +67,7 @@ from app.models import (
|
||||
get_metrics,
|
||||
get_notes,
|
||||
get_picks,
|
||||
get_project_activity,
|
||||
get_stats,
|
||||
init_db,
|
||||
log_event,
|
||||
@@ -154,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())
|
||||
@@ -178,6 +195,7 @@ async def lifespan(app: FastAPI):
|
||||
settings.capture_ntfy_topic,
|
||||
)
|
||||
yield
|
||||
snapshot_task.cancel()
|
||||
scheduler.cancel()
|
||||
digest_task.cancel()
|
||||
rules_task.cancel()
|
||||
@@ -191,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:
|
||||
@@ -219,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:
|
||||
@@ -329,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:
|
||||
@@ -377,6 +510,27 @@ def _parse_datetime(value: str | None) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _parallel_map(items, fn, limit: int = 16) -> list:
|
||||
"""Run an async fn over items with bounded concurrency (0.19.0).
|
||||
|
||||
Cross-project pages used to await each repo in sequence, so the
|
||||
page took the SUM of every Gitea round trip. Gathering with a
|
||||
per-call semaphore (created here, bound to the current loop)
|
||||
cuts that to roughly the slowest single repo, without letting
|
||||
a big instance stampede Gitea. Exceptions come back in place —
|
||||
callers decide what a failed repo means for them.
|
||||
"""
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
async def _run(item):
|
||||
async with semaphore:
|
||||
return await fn(item)
|
||||
|
||||
return await asyncio.gather(
|
||||
*(_run(item) for item in items), return_exceptions=True
|
||||
)
|
||||
|
||||
|
||||
def classify_repos(
|
||||
repos: list[dict], now: datetime | None = None
|
||||
) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
@@ -584,7 +738,7 @@ async def dashboard(request: Request):
|
||||
except Exception as exc: # stars are decoration, never fatal (#20)
|
||||
logger.debug("starred list failed: %s", exc)
|
||||
starred_names = set()
|
||||
for repo_info in active:
|
||||
async def _enrich_active(repo_info: dict) -> None:
|
||||
full_name = repo_info["full_name"]
|
||||
owner, _, repo = full_name.partition("/")
|
||||
repo_info["forecast_label"] = _repo_forecast_label(
|
||||
@@ -598,8 +752,28 @@ async def dashboard(request: Request):
|
||||
logger.debug("health dot failed for %s: %s", full_name, exc)
|
||||
repo_info["health_dot"] = None
|
||||
repo_info["starred"] = full_name in starred_names
|
||||
|
||||
await _parallel_map(active, _enrich_active)
|
||||
for repo_info in inactive:
|
||||
repo_info["starred"] = repo_info["full_name"] in starred_names
|
||||
activity_by_repo = get_project_activity()
|
||||
today_utc = datetime.now(timezone.utc).date()
|
||||
for repo_info in active + inactive + archived:
|
||||
act = activity_by_repo.get(repo_info["full_name"])
|
||||
if act is None:
|
||||
repo_info["activity"] = None
|
||||
continue
|
||||
idle_days = None
|
||||
if act["last_at"]:
|
||||
last_dt = _parse_datetime(act["last_at"])
|
||||
if last_dt is not None:
|
||||
idle_days = (today_utc - last_dt.date()).days
|
||||
repo_info["activity"] = {
|
||||
"closed": act["closed"],
|
||||
"moved": act["moved"],
|
||||
"idle_days": idle_days,
|
||||
"last_at": act["last_at"] or "",
|
||||
}
|
||||
for repo_info in active:
|
||||
repo_info["home_tab"] = "active"
|
||||
for repo_info in inactive:
|
||||
@@ -994,19 +1168,18 @@ async def triage_picker(request: Request):
|
||||
"""Triage mode (issue #11): pick a project by its backlog size."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _one_triage(repo_info: dict) -> dict | None:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
all_items = await gitea.get_repo_issues(owner, name, state="all")
|
||||
except Exception:
|
||||
continue
|
||||
all_items = await gitea.get_repo_issues(owner, name, state="all")
|
||||
columns, _display, _state = _board_columns_for(owner, name, all_items)
|
||||
backlog = len(columns["backlog"])
|
||||
if backlog:
|
||||
projects.append(
|
||||
{"full_name": repo_info["full_name"], "backlog": backlog}
|
||||
)
|
||||
return {"full_name": repo_info["full_name"], "backlog": backlog}
|
||||
return None
|
||||
|
||||
results = await _parallel_map(active, _one_triage)
|
||||
projects = [r for r in results if isinstance(r, dict)]
|
||||
projects.sort(key=lambda p: -p["backlog"])
|
||||
return render(request, "triage.html", projects=projects)
|
||||
|
||||
@@ -1535,30 +1708,52 @@ async def health_index(request: Request):
|
||||
"""Flow health, global summary (issue #9)."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _one_health(repo_info: dict) -> dict:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
projects.append(await _project_health(owner, name))
|
||||
except Exception as exc:
|
||||
logger.warning("Health failed for %s: %s", repo_info["full_name"], exc)
|
||||
return await _project_health(owner, name)
|
||||
|
||||
results = await _parallel_map(active, _one_health)
|
||||
projects = []
|
||||
for repo_info, result in zip(active, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(
|
||||
"Health failed for %s: %s", repo_info["full_name"], result
|
||||
)
|
||||
continue
|
||||
result["dot"] = _health_dot(result)
|
||||
projects.append(result)
|
||||
severity_rank = {"red": 0, "orange": 1, "green": 2}
|
||||
projects.sort(
|
||||
key=lambda p: (
|
||||
severity_rank.get(p["dot"]["level"], 3),
|
||||
-len(p["zombies"]),
|
||||
)
|
||||
)
|
||||
return render(request, "health.html", projects=projects)
|
||||
|
||||
|
||||
@app.get("/health/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def health_project(request: Request, owner: str, repo: str):
|
||||
"""Flow health of one project: dwell per column + zombies."""
|
||||
"""Flow health of one project: dwell per column + zombies.
|
||||
|
||||
Passes the active project list so the page can offer a project
|
||||
switcher — no more back-to-the-index round trip (0.19.0, C).
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
known = {r["full_name"] for r in repos}
|
||||
if f"{owner}/{repo}" not in known:
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
project_names = [r["full_name"] for r in active]
|
||||
if f"{owner}/{repo}" not in {r["full_name"] for r in repos}:
|
||||
return render(
|
||||
request, "health_project.html",
|
||||
health=None, full_name=f"{owner}/{repo}",
|
||||
project_names=project_names,
|
||||
)
|
||||
health = await _project_health(owner, repo)
|
||||
return render(
|
||||
request, "health_project.html",
|
||||
health=health, full_name=f"{owner}/{repo}",
|
||||
project_names=project_names,
|
||||
)
|
||||
|
||||
|
||||
@@ -1585,11 +1780,29 @@ async def standup_page(request: Request):
|
||||
|
||||
@app.get("/forecast", response_class=HTMLResponse)
|
||||
async def forecast_page(request: Request):
|
||||
"""Forecasts (issue #7): real-pace ETAs per project + milestone."""
|
||||
"""Forecasts (issue #7; portfolio table in 0.19.0, chantier C).
|
||||
|
||||
One row per open milestone across every active project,
|
||||
sorted by risk (largest ETA-vs-deadline gap first) — the
|
||||
cross-project question the old per-project card wall could
|
||||
not answer without reading everything. Per-project pace
|
||||
(weekly bars + backlog ETA) stays as a compact second table.
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _fetch_milestones(repo_info: dict) -> list[dict]:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
return await gitea.get_milestones(owner, name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
ms_results = await _parallel_map(active, _fetch_milestones)
|
||||
|
||||
rows: list[dict] = []
|
||||
projects: list[dict] = []
|
||||
for repo_info, ms_list in zip(active, ms_results):
|
||||
full = repo_info["full_name"]
|
||||
owner, _, name = full.partition("/")
|
||||
weekly = forecast_service.weekly_closures(
|
||||
@@ -1604,50 +1817,100 @@ async def forecast_page(request: Request):
|
||||
}
|
||||
for b in weekly
|
||||
]
|
||||
stats = forecast_service.throughput_stats(weekly)
|
||||
remaining = repo_info.get("open_issues_count") or 0
|
||||
est = forecast_service.estimate(remaining, weekly)
|
||||
milestones = []
|
||||
try:
|
||||
ms_list = await gitea.get_milestones(owner, name)
|
||||
except Exception:
|
||||
ms_list = []
|
||||
for ms in ms_list:
|
||||
if ms.get("state") not in (None, "open"):
|
||||
continue
|
||||
rem = ms.get("open_issues") or 0
|
||||
if rem == 0 and not (ms.get("closed_issues") or 0):
|
||||
continue
|
||||
m_est = forecast_service.estimate(rem, weekly)
|
||||
verdict = None
|
||||
due_raw = ms.get("due_on")
|
||||
if m_est and not m_est.get("done") and due_raw:
|
||||
due = _parse_datetime(str(due_raw))
|
||||
if due is not None:
|
||||
verdict = (
|
||||
"⚠️ en retard à ce rythme"
|
||||
if m_est["central"] > due.date()
|
||||
else "✅ dans les temps à ce rythme"
|
||||
)
|
||||
milestones.append(
|
||||
{
|
||||
"title": ms.get("title", ""),
|
||||
"due_on": (due_raw or "")[:10],
|
||||
"remaining": rem,
|
||||
"eta": forecast_service.eta_label(m_est),
|
||||
"verdict": verdict,
|
||||
}
|
||||
)
|
||||
projects.append(
|
||||
{
|
||||
"full_name": full,
|
||||
"bars": bars,
|
||||
"stats": forecast_service.throughput_stats(weekly),
|
||||
"stats": stats,
|
||||
"remaining": remaining,
|
||||
"eta_label": forecast_service.eta_label(est),
|
||||
"milestones": milestones,
|
||||
}
|
||||
)
|
||||
return render(request, "forecast.html", projects=projects)
|
||||
if isinstance(ms_list, Exception):
|
||||
ms_list = []
|
||||
next_title = None
|
||||
next_due = None
|
||||
for ms in ms_list:
|
||||
if ms.get("state") not in (None, "open"):
|
||||
continue
|
||||
rem = ms.get("open_issues") or 0
|
||||
closed_n = ms.get("closed_issues") or 0
|
||||
if rem == 0 and not closed_n:
|
||||
continue
|
||||
total = rem + closed_n
|
||||
m_est = forecast_service.estimate(rem, weekly)
|
||||
due_raw = ms.get("due_on")
|
||||
due_dt = _parse_datetime(str(due_raw)) if due_raw else None
|
||||
due_date = due_dt.date() if due_dt else None
|
||||
gap_days = None
|
||||
verdict = "noeta"
|
||||
if due_date is None:
|
||||
verdict = "nodue"
|
||||
elif m_est and not m_est.get("done"):
|
||||
gap_days = (m_est["central"] - due_date).days
|
||||
verdict = "late" if gap_days > 0 else "ontime"
|
||||
elif m_est and m_est.get("done"):
|
||||
verdict = "ontime"
|
||||
rows.append(
|
||||
{
|
||||
"project": full,
|
||||
"owner": owner,
|
||||
"repo": name,
|
||||
"title": ms.get("title", ""),
|
||||
"due_label": (
|
||||
forecast_service.french_date(due_date, short=True)
|
||||
if due_date else "—"
|
||||
),
|
||||
"due_sort": due_date.isoformat() if due_date else "9999",
|
||||
"remaining": rem,
|
||||
"closed": closed_n,
|
||||
"total": total,
|
||||
"pct": round(100 * closed_n / total) if total else 0,
|
||||
"eta": forecast_service.eta_label(m_est),
|
||||
"gap_days": gap_days,
|
||||
"verdict": verdict,
|
||||
"throughput": stats["avg"] if stats else None,
|
||||
"anchor": False,
|
||||
}
|
||||
)
|
||||
if (
|
||||
due_date is not None
|
||||
and (next_due is None or due_date < next_due)
|
||||
):
|
||||
next_due, next_title = due_date, ms.get("title", "")
|
||||
for row in rows:
|
||||
if (
|
||||
row["project"] == full
|
||||
and row["title"] == next_title
|
||||
and next_title is not None
|
||||
):
|
||||
row["anchor"] = True
|
||||
|
||||
def _risk_key(row: dict):
|
||||
# Late first (worst gap first), then dated on-time by due
|
||||
# date, then no-ETA, then no due date.
|
||||
order = {"late": 0, "ontime": 1, "noeta": 2, "nodue": 3}
|
||||
return (
|
||||
order.get(row["verdict"], 4),
|
||||
-(row["gap_days"] or 0) if row["verdict"] == "late" else 0,
|
||||
row["due_sort"],
|
||||
row["project"],
|
||||
row["title"],
|
||||
)
|
||||
|
||||
rows.sort(key=_risk_key)
|
||||
summary = {
|
||||
"late": sum(1 for r in rows if r["verdict"] == "late"),
|
||||
"ontime": sum(1 for r in rows if r["verdict"] == "ontime"),
|
||||
"total": len(rows),
|
||||
}
|
||||
return render(
|
||||
request, "forecast.html",
|
||||
rows=rows, projects=projects, summary=summary,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
@@ -1720,15 +1983,14 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
|
||||
closed_today.get(event["repo_full_name"], 0) + 1
|
||||
)
|
||||
|
||||
slides = []
|
||||
for repo in repos:
|
||||
async def _one_slide(repo: dict) -> dict | None:
|
||||
full_name = repo["full_name"]
|
||||
owner, _, name = full_name.partition("/")
|
||||
try:
|
||||
items = await gitea.get_repo_issues(owner, name, state="open")
|
||||
except Exception as exc:
|
||||
logger.debug("wall: issues failed for %s: %s", full_name, exc)
|
||||
continue
|
||||
return None
|
||||
open_issues = [i for i in items if _is_issue(i)]
|
||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||
board_state = get_board_state(full_name)
|
||||
@@ -1750,17 +2012,17 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
|
||||
"aging": days is not None and days >= aging_days,
|
||||
}
|
||||
)
|
||||
slides.append(
|
||||
{
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"cards_total": len(cards),
|
||||
"closed_today": closed_today.get(full_name, 0),
|
||||
}
|
||||
)
|
||||
return slides
|
||||
return {
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"cards_total": len(cards),
|
||||
"closed_today": closed_today.get(full_name, 0),
|
||||
}
|
||||
|
||||
results = await _parallel_map(repos, _one_slide)
|
||||
return [r for r in results if isinstance(r, dict)]
|
||||
|
||||
|
||||
@app.get("/wall", response_class=HTMLResponse)
|
||||
@@ -2173,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}
|
||||
)
|
||||
@@ -2305,13 +2574,17 @@ async def api_capture(request: Request):
|
||||
|
||||
|
||||
@app.get("/capture", response_class=HTMLResponse)
|
||||
async def capture_page(request: Request):
|
||||
"""Quick-capture page: form, status and usage instructions."""
|
||||
async def capture_page(request: Request, repo: str = ""):
|
||||
"""Quick-capture page: form, status and usage instructions.
|
||||
|
||||
`?repo=Owner/name` pre-fills the target repo (issue #23: the
|
||||
project card's quick actions capture straight into a project).
|
||||
"""
|
||||
settings = get_settings()
|
||||
return render(
|
||||
request,
|
||||
"capture.html",
|
||||
capture_repo=settings.capture_repo,
|
||||
capture_repo=repo.strip() or settings.capture_repo,
|
||||
api_enabled=bool(settings.capture_token),
|
||||
ntfy_topic=settings.capture_ntfy_topic,
|
||||
ntfy_url=settings.ntfy_url,
|
||||
@@ -3379,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")
|
||||
@@ -368,6 +417,38 @@ def column_entry_times(repo_full_name: str) -> dict[int, str]:
|
||||
return {row["issue_number"]: row["entered_at"] for row in rows}
|
||||
|
||||
|
||||
def get_project_activity(days: int = 7) -> dict:
|
||||
"""Per-repo journal activity in ONE aggregated pass (issue #19).
|
||||
|
||||
For every repo present in the journal: closures and moves of
|
||||
the last `days` days (journal stamps are UTC strings), plus
|
||||
the most recent event ever seen for that repo.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT repo_full_name,
|
||||
SUM(CASE WHEN event_type = 'closed' AND created_at >= ?
|
||||
THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN event_type = 'moved' AND created_at >= ?
|
||||
THEN 1 ELSE 0 END),
|
||||
MAX(created_at)
|
||||
FROM board_events
|
||||
GROUP BY repo_full_name
|
||||
""",
|
||||
(cutoff, cutoff),
|
||||
).fetchall()
|
||||
return {
|
||||
r[0]: {"closed": r[1] or 0, "moved": r[2] or 0, "last_at": r[3]}
|
||||
for r in rows
|
||||
}
|
||||
|
||||
|
||||
def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dict:
|
||||
"""Throughput and cycle time computed from the journal.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+142
-31
@@ -11,45 +11,80 @@
|
||||
<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>
|
||||
|
||||
<main x-data="{ tab: 'active', search: '' }">
|
||||
<!-- 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>
|
||||
<script>
|
||||
// État du dashboard (#21/#22) : la vue grille/liste survit au
|
||||
// rechargement (localStorage), lue avant qu'Alpine démarre.
|
||||
function dashState() {
|
||||
var view = 'grid';
|
||||
try { view = localStorage.getItem('kanbante-dash-view') || 'grid'; } catch (e) { /* défaut */ }
|
||||
return {
|
||||
tab: 'active', search: '', view: view,
|
||||
setView: function (v) {
|
||||
this.view = v;
|
||||
try { localStorage.setItem('kanbante-dash-view', v); } catch (e) { /* non-fatal */ }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<main x-data="dashState()">
|
||||
<!-- 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>
|
||||
<option value="activity">Activité kanban récente</option>
|
||||
<option value="open">Issues ouvertes ↓</option>
|
||||
<option value="name">Nom A → Z</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="dash-tool dash-tool-check">
|
||||
<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>
|
||||
<button type="button" :class="{ active: view === 'list' }" @click="setView('list')">☰ Liste</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% macro project_card(repo, archived=False) %}
|
||||
{% set full = repo.full_name %}
|
||||
<div class="project-card{% if archived %} archived{% endif %}"
|
||||
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
|
||||
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
|
||||
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
|
||||
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
|
||||
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||
<div class="card-header">
|
||||
@@ -60,6 +95,19 @@
|
||||
<span class="date">{{ repo.updated_at[:10] }}</span>
|
||||
{% if not archived %}
|
||||
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
|
||||
<div class="card-menu-wrap">
|
||||
<button type="button" class="card-menu-btn" data-menu-btn aria-haspopup="true" aria-expanded="false" title="Actions rapides pour {{ repo.name }}" aria-label="Actions rapides pour {{ repo.name }}">⋯</button>
|
||||
<div class="card-menu" hidden>
|
||||
<a href="/board/{{ full }}">📋 Board</a>
|
||||
<a href="/capture?repo={{ full|urlencode }}">⚡ Capturer ici</a>
|
||||
<a href="/triage/{{ full }}">📥 Triage du backlog</a>
|
||||
<a href="/health/{{ full }}">🩺 Santé</a>
|
||||
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
|
||||
{% if repo.html_url %}
|
||||
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3>
|
||||
@@ -79,7 +127,7 @@
|
||||
{% set ms = repo.next_milestone %}
|
||||
<div class="ms-block">
|
||||
<div class="ms-line">
|
||||
<span class="ms-title">🎯 {{ ms.title }}</span>
|
||||
<a href="/forecast#ms-{{ repo.owner.login }}-{{ repo.name }}" class="ms-title" title="Voir ce jalon dans le tableau des prévisions">🎯 {{ ms.title }}</a>
|
||||
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
|
||||
</div>
|
||||
{% if ms.total %}
|
||||
@@ -107,9 +155,62 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if repo.activity %}
|
||||
<div class="activity-line">
|
||||
{% if repo.activity.closed or repo.activity.moved %}
|
||||
📈 {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s) cette semaine
|
||||
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
|
||||
💤 aucune activité kanban depuis {{ repo.activity.idle_days }} j
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro project_row(repo, archived=False) %}
|
||||
{% set full = repo.full_name %}
|
||||
{% set st = stats.get(full, {}) %}
|
||||
<tr data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login|lower }}"
|
||||
data-open="{{ repo.open_issues_count or 0 }}"
|
||||
data-inflight="{{ (st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0)) }}"
|
||||
data-updated="{{ repo.updated_at }}"
|
||||
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||
<td><a href="/project/{{ full }}"><strong>{{ repo.name }}</strong></a></td>
|
||||
<td>{{ repo.owner.login }}</td>
|
||||
<td>{{ repo.open_issues_count or 0 }}</td>
|
||||
<td>{{ st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0) }}</td>
|
||||
<td>{% if repo.next_milestone %}🎯 {{ repo.next_milestone.title }}{% if repo.next_milestone.late %} ⚠️{% endif %}{% else %}—{% endif %}</td>
|
||||
<td>{% if repo.health_dot %}<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="{{ repo.health_dot.tooltip }}" aria-label="Santé : {{ repo.health_dot.tooltip }}"></a>{% else %}—{% endif %}</td>
|
||||
<td>{{ repo.updated_at[:10] }}</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro project_table(repos, tab_name, archived=False) %}
|
||||
<div x-show="tab === '{{ tab_name }}'">
|
||||
<table class="proj-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name">Projet</th>
|
||||
<th data-sort="owner">Propriétaire</th>
|
||||
<th data-sort="open">Ouvertes</th>
|
||||
<th data-sort="inflight">En vol</th>
|
||||
<th>Prochain jalon</th>
|
||||
<th>Santé</th>
|
||||
<th data-sort="updated" data-first-dir="desc">Dernière activité</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for repo in repos %}{{ project_row(repo, archived) }}{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not repos %}
|
||||
<div class="empty-state">Aucun projet dans cette vue.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div x-show="view === 'grid'">
|
||||
<!-- Épinglés (#20) : les dépôts étoilés dans Gitea, hors onglets. -->
|
||||
<section id="pinned-section"{% if not pinned_repos %} hidden{% endif %}>
|
||||
<h3 class="pinned-title">⭐ Épinglés</h3>
|
||||
@@ -138,6 +239,14 @@
|
||||
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue liste dense (#22) : mêmes onglets, mêmes données. -->
|
||||
<div x-show="view === 'list'">
|
||||
{{ project_table(active_repos, 'active') }}
|
||||
{{ project_table(inactive_repos, 'inactive') }}
|
||||
{{ project_table(archived_repos, 'archived', archived=True) }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
@@ -146,6 +255,8 @@
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
|
||||
<script src="/static/js/stars.js" defer></script>
|
||||
<script src="/static/js/dashboard.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
<script>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
|
||||
|
||||
+78
-18
@@ -13,13 +13,72 @@
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🔮 Prévisions — « on finit quand ? »</h1>
|
||||
<p>Réponse honnête, calculée depuis le journal : fermetures réelles par semaine (8 dernières), débit moyen/min/max sur les 4 dernières semaines complètes, et fourchette plutôt que faux chiffre précis. Sans historique de fermetures, aucune prévision n'est affichée.</p>
|
||||
<p>Le tableau de bord des jalons, <strong>tous projets confondus</strong>, trié par risque : l'écart entre la date estimée (au débit réel du journal) et l'échéance. Réponse honnête — fourchettes plutôt que faux chiffres précis, et aucune prévision sans historique de fermetures.</p>
|
||||
<p class="forecast-summary">
|
||||
<span class="chip chip-total">🎯 {{ summary.total }} jalon(s) ouvert(s)</span>
|
||||
{% if summary.late %}<span class="chip chip-late">⚠️ {{ summary.late }} en retard à ce rythme</span>{% endif %}
|
||||
{% if summary.ontime %}<span class="chip chip-ok">✅ {{ summary.ontime }} dans les temps</span>{% endif %}
|
||||
</p>
|
||||
<div class="search-bar forecast-filter">
|
||||
<input type="text" id="ms-filter" placeholder="🔍 Filtrer par projet ou jalon..." autocomplete="off">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }}</h2>
|
||||
{% if rows %}
|
||||
<table class="proj-table pilot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="project">Projet</th>
|
||||
<th data-sort="title">Jalon</th>
|
||||
<th data-sort="due">Échéance</th>
|
||||
<th>Progression</th>
|
||||
<th>ETA</th>
|
||||
<th data-sort="gap" data-first-dir="desc">Écart</th>
|
||||
<th data-sort="throughput">Débit / sem.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr{% if row.anchor %} id="ms-{{ row.owner }}-{{ row.repo }}"{% endif %}
|
||||
data-project="{{ row.project|lower }}" data-title="{{ row.title|lower }}"
|
||||
data-due="{{ row.due_sort }}" data-gap="{{ row.gap_days if row.gap_days is not none else '' }}"
|
||||
data-throughput="{{ row.throughput if row.throughput is not none else '' }}"
|
||||
data-search="{{ (row.project ~ ' ' ~ row.title)|lower }}">
|
||||
<td><a href="/project/{{ row.project }}">{{ row.project }}</a></td>
|
||||
<td><a href="/board/{{ row.project }}?milestone={{ row.title|urlencode }}"><strong>{{ row.title }}</strong></a></td>
|
||||
<td>{{ row.due_label }}</td>
|
||||
<td>
|
||||
{% if row.total %}
|
||||
<div class="progress ms-progress pilot-progress"><span style="width: {{ row.pct }}%"></span></div>
|
||||
<span class="ms-meta">{{ row.closed }}/{{ row.total }}</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{{ row.eta or '—' }}</td>
|
||||
<td>
|
||||
{% if row.verdict == 'late' %}
|
||||
<span class="verdict verdict-late">⚠️ +{{ row.gap_days }} j</span>
|
||||
{% elif row.verdict == 'ontime' %}
|
||||
<span class="verdict verdict-ok">✅ dans les temps{% if row.gap_days is not none and row.gap_days < 0 %} ({{ -row.gap_days }} j d'avance){% endif %}</span>
|
||||
{% elif row.verdict == 'nodue' %}
|
||||
<span class="verdict">sans échéance</span>
|
||||
{% else %}
|
||||
<span class="verdict">pas d'ETA</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ row.throughput if row.throughput is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<section class="briefing-card"><p>Aucun jalon ouvert dans les projets actifs. Crée des jalons dans Gitea pour piloter par échéances.</p></section>
|
||||
{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📈 Débit par projet</h2>
|
||||
{% for project in projects %}
|
||||
<div class="forecast-flex">
|
||||
<div class="forecast-proj-name">📂 <a href="/project/{{ project.full_name }}">{{ project.full_name }}</a></div>
|
||||
<svg class="sparkline" viewBox="0 0 168 48" role="img" aria-label="Fermetures par semaine">
|
||||
{% for bar in project.bars %}
|
||||
{% set px = (bar.h * 0.42)|round|int %}
|
||||
@@ -30,25 +89,17 @@
|
||||
</svg>
|
||||
<div class="forecast-nums">
|
||||
{% if project.stats %}
|
||||
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> en moyenne (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
|
||||
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
|
||||
{% else %}
|
||||
<p>Débit : <strong>pas assez d'historique</strong> — aucune fermeture récente dans le journal.</p>
|
||||
<p>Débit : <strong>pas assez d'historique</strong>.</p>
|
||||
{% endif %}
|
||||
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>
|
||||
{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
|
||||
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if project.milestones %}
|
||||
<ul class="forecast-milestones">
|
||||
{% for ms in project.milestones %}
|
||||
<li>🎯 <strong>{{ ms.title }}</strong> — {{ ms.remaining }} restante(s){% if ms.due_on %} · échéance {{ ms.due_on }}{% endif %}{% if ms.eta %} · 🔮 {{ ms.eta }}{% endif %}{% if ms.verdict %} · {{ ms.verdict }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Aucun projet actif.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||
{% endfor %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · prévisions = journal des fermetures, pas boule de cristal</span>
|
||||
@@ -56,5 +107,14 @@
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
<script>
|
||||
document.getElementById('ms-filter')?.addEventListener('input', function (e) {
|
||||
var q = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.pilot-table tbody tr').forEach(function (row) {
|
||||
row.style.display = !q || (row.dataset.search || '').includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+36
-14
@@ -13,23 +13,44 @@
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 Santé du flux</h1>
|
||||
<p>Les boards montrent l'état ; cette page montre la <strong>santé</strong> : quelle colonne accumule (temps médian passé dans chaque colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong> (aucune activité depuis le seuil configuré).</p>
|
||||
<p>Tous les projets actifs, <strong>triés par sévérité</strong> — le rouge d'abord. Quelle colonne accumule (temps médian par colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong>. Cliquez un projet pour le détail et les actions rapides.</p>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }} <small>({{ project.open_count }} ouverte(s))</small></h2>
|
||||
{% if project.slowest %}
|
||||
<p>🐌 Colonne la plus lente : <strong>{{ project.column_labels[project.slowest.column] }}</strong> — médiane {{ project.slowest.median_days }} j ({{ project.slowest.count }} séjours mesurés)</p>
|
||||
{% else %}
|
||||
<p>🐌 Pas encore assez de séjours mesurés pour nommer un goulot.</p>
|
||||
{% endif %}
|
||||
<p>🧟 Zombies (≥ {{ project.zombie_days }} j sans activité) : <strong>{{ project.zombies|length }}</strong>
|
||||
— <a href="/health/{{ project.full_name }}">voir le détail et agir →</a></p>
|
||||
</section>
|
||||
{% if projects %}
|
||||
<table class="proj-table pilot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="severity">Santé</th>
|
||||
<th data-sort="project">Projet</th>
|
||||
<th data-sort="open">Ouvertes</th>
|
||||
<th data-sort="zombies" data-first-dir="desc">🧟 Zombies</th>
|
||||
<th data-sort="slowest">🐌 Goulot</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr data-severity="{{ {'red': 0, 'orange': 1, 'green': 2}.get(project.dot.level, 3) }}"
|
||||
data-project="{{ project.full_name|lower }}" data-open="{{ project.open_count }}"
|
||||
data-zombies="{{ project.zombies|length }}"
|
||||
data-slowest="{{ project.slowest.median_days if project.slowest else '' }}">
|
||||
<td><span class="health-dot {{ project.dot.level }}" title="{{ project.dot.title }}"></span></td>
|
||||
<td><a href="/health/{{ project.full_name }}"><strong>{{ project.full_name }}</strong></a></td>
|
||||
<td>{{ project.open_count }}</td>
|
||||
<td>{{ project.zombies|length }}{% if project.zombies %} <span class="ms-meta">(≥ {{ project.zombie_days }} j)</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if project.slowest %}
|
||||
{{ project.column_labels[project.slowest.column] }} — médiane {{ project.slowest.median_days }} j
|
||||
{% else %}<span class="ms-meta">pas assez de données</span>{% endif %}
|
||||
</td>
|
||||
<td><a href="/health/{{ project.full_name }}">détail →</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||
{% endfor %}
|
||||
<section class="briefing-card"><p>Aucun projet actif.</p></section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
|
||||
@@ -37,5 +58,6 @@
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
{% if project_names %}
|
||||
<div class="health-switch-bar">
|
||||
<label for="health-switch">🩺 Projet :</label>
|
||||
<select id="health-switch">
|
||||
{% for name in project_names %}
|
||||
<option value="{{ name }}"{% if name == full_name %} selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
{% if full_name not in project_names %}<option value="{{ full_name }}" selected>{{ full_name }}</option>{% endif %}
|
||||
</select>
|
||||
<a href="/health" class="health-back">← tous les projets</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if health is none %}
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 {{ full_name }}</h1>
|
||||
|
||||
@@ -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.17.0"
|
||||
version = "0.22.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+281
-44
@@ -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; }
|
||||
@@ -1371,3 +1389,222 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
.proj-dim { color: var(--text-3); }
|
||||
.proj-empty { color: var(--text-2); font-size: 0.92rem; }
|
||||
.proj-notes-body { white-space: pre-wrap; font-size: 0.93rem; background: var(--surface-2); border-radius: 10px; padding: 12px 14px; }
|
||||
|
||||
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
|
||||
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
|
||||
/* 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;
|
||||
}
|
||||
.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: 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; 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; }
|
||||
.owner-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
width: 100%; padding: 9px 12px; margin-bottom: 10px; cursor: pointer;
|
||||
background: var(--surface-1); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
font-size: 0.95rem; font-weight: 700;
|
||||
}
|
||||
.owner-head:hover { border-color: var(--accent, #58a6ff); }
|
||||
.owner-head .owner-chevron { transition: transform 0.2s ease; font-size: 0.7rem; }
|
||||
.owner-head[aria-expanded="false"] .owner-chevron { transform: rotate(-90deg); }
|
||||
.owner-grid { padding: 0 !important; }
|
||||
.card-menu-wrap { position: relative; }
|
||||
.card-menu-btn {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
color: var(--text-2); font-size: 1.15rem; font-weight: 800;
|
||||
padding: 2px 7px; border-radius: 8px; line-height: 1;
|
||||
}
|
||||
.card-menu-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.card-menu {
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
|
||||
min-width: 210px; display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 6px;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.card-menu[hidden] { display: none !important; }
|
||||
.card-menu a {
|
||||
display: flex; align-items: center; min-height: 32px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
color: var(--text-2); font-size: 0.92rem;
|
||||
}
|
||||
.card-menu a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
|
||||
.proj-table {
|
||||
width: calc(100% - 48px); margin: 0 24px 18px;
|
||||
border-collapse: collapse; font-size: 0.93rem;
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 12px; overflow: hidden;
|
||||
}
|
||||
.proj-table th, .proj-table td {
|
||||
text-align: left; padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.proj-table thead th { color: var(--text-2); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.proj-table th.sortable { cursor: pointer; }
|
||||
.proj-table th.sortable:hover { color: var(--text); }
|
||||
.proj-table th[data-dir="asc"]::after { content: " ↑"; }
|
||||
.proj-table th[data-dir="desc"]::after { content: " ↓"; }
|
||||
.proj-table tbody tr:hover { background: var(--surface-2); }
|
||||
@media (max-width: 900px) {
|
||||
.proj-table { display: block; overflow-x: auto; }
|
||||
.dash-view-toggle { margin-left: 0; }
|
||||
}
|
||||
|
||||
/* ── Pilotage 0.19.0 : tableaux Prévisions/Santé (chantier C) ── */
|
||||
.pilot-table { margin-bottom: 18px; }
|
||||
.pilot-table tr[id] { scroll-margin-top: 76px; }
|
||||
.pilot-progress { min-width: 90px; }
|
||||
.forecast-summary { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 4px; }
|
||||
.forecast-summary .chip {
|
||||
font-size: 12.5px; padding: 3px 10px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .07); border: 1px solid var(--border, #333);
|
||||
}
|
||||
.forecast-summary .chip-late { background: rgba(248, 81, 73, .15); }
|
||||
.forecast-summary .chip-ok { background: rgba(63, 185, 80, .15); }
|
||||
.forecast-filter { margin-top: 10px; max-width: 420px; }
|
||||
.forecast-filter input { width: 100%; }
|
||||
.forecast-proj-name { font-weight: 600; min-width: 180px; }
|
||||
.verdict {
|
||||
font-size: 12.5px; padding: 2px 9px; border-radius: 999px; white-space: nowrap;
|
||||
background: rgba(255, 255, 255, .06);
|
||||
}
|
||||
.verdict-late { background: rgba(248, 81, 73, .18); }
|
||||
.verdict-ok { background: rgba(63, 185, 80, .16); }
|
||||
a.ms-title { color: inherit; text-decoration: none; }
|
||||
a.ms-title:hover { text-decoration: underline; }
|
||||
.health-switch-bar {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.health-switch-bar select {
|
||||
padding: 8px 10px; border-radius: 8px; font-size: 14px; min-height: 38px;
|
||||
background: var(--panel-2, #1c2330); color: inherit;
|
||||
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; }
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Kanbante — dashboard display tools (issues #21, #22, #23).
|
||||
* Sort + group-by-owner for the card grids, sortable dense tables,
|
||||
* and the per-card quick-actions menu. Preferences live in
|
||||
* localStorage; the server markup stays the single source.
|
||||
*/
|
||||
(function () {
|
||||
var LS_SORT = 'kanbante-dash-sort';
|
||||
var LS_GROUP = 'kanbante-dash-group';
|
||||
var LS_OWNERS = 'kanbante-dash-owners-closed';
|
||||
|
||||
function lsGet(key, fallback) {
|
||||
try {
|
||||
var v = localStorage.getItem(key);
|
||||
return v === null ? fallback : v;
|
||||
} catch (e) { return fallback; }
|
||||
}
|
||||
function lsSet(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
/* ── Tri des grilles de cartes ─────────────────────────── */
|
||||
var sortSelect = document.getElementById('dash-sort');
|
||||
// 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);
|
||||
|
||||
function cardKey(card, mode) {
|
||||
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
|
||||
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
|
||||
if (mode === 'updated') return card.getAttribute('data-updated') || '';
|
||||
if (mode === 'activity') return card.getAttribute('data-activity') || '';
|
||||
return null; // 'default' : ordre serveur, on ne touche à rien
|
||||
}
|
||||
function sortCards(container, mode) {
|
||||
var cards = Array.prototype.slice.call(
|
||||
container.querySelectorAll('.project-card'));
|
||||
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;
|
||||
});
|
||||
cards.forEach(function (c) { container.appendChild(c); });
|
||||
}
|
||||
function applySort() {
|
||||
var mode = sortSelect ? sortSelect.value : 'default';
|
||||
lsSet(LS_SORT, mode);
|
||||
grids.forEach(function (grid) {
|
||||
if (groupToggle && groupToggle.checked) {
|
||||
grid.querySelectorAll('.owner-grid').forEach(function (og) {
|
||||
sortCards(og, mode);
|
||||
});
|
||||
} else {
|
||||
sortCards(grid, mode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Regroupement par propriétaire ─────────────────────── */
|
||||
var groupToggle = document.getElementById('dash-group');
|
||||
function closedOwners() {
|
||||
try { return JSON.parse(lsGet(LS_OWNERS, '[]')); } catch (e) { return []; }
|
||||
}
|
||||
function setOwnerClosed(owner, closed) {
|
||||
var list = closedOwners().filter(function (o) { return o !== owner; });
|
||||
if (closed) list.push(owner);
|
||||
lsSet(LS_OWNERS, JSON.stringify(list));
|
||||
}
|
||||
function groupGrid(grid) {
|
||||
var cards = Array.prototype.slice.call(
|
||||
grid.querySelectorAll(':scope > .project-card'));
|
||||
if (!cards.length) return;
|
||||
var byOwner = {};
|
||||
cards.forEach(function (c) {
|
||||
var o = c.getAttribute('data-owner') || '—';
|
||||
(byOwner[o] = byOwner[o] || []).push(c);
|
||||
});
|
||||
Object.keys(byOwner).sort().forEach(function (owner) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'owner-group';
|
||||
var head = document.createElement('button');
|
||||
head.type = 'button';
|
||||
head.className = 'owner-head';
|
||||
var closed = closedOwners().indexOf(owner) >= 0;
|
||||
head.setAttribute('aria-expanded', closed ? 'false' : 'true');
|
||||
head.innerHTML = '<span>🗂️ ' + owner + ' (' + byOwner[owner].length + ')</span><span class="owner-chevron">▾</span>';
|
||||
var body = document.createElement('div');
|
||||
body.className = 'project-grid owner-grid';
|
||||
if (closed) body.hidden = true;
|
||||
head.addEventListener('click', function () {
|
||||
body.hidden = !body.hidden;
|
||||
head.setAttribute('aria-expanded', body.hidden ? 'false' : 'true');
|
||||
setOwnerClosed(owner, body.hidden);
|
||||
});
|
||||
byOwner[owner].forEach(function (c) { body.appendChild(c); });
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(body);
|
||||
grid.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
function ungroupGrid(grid) {
|
||||
grid.querySelectorAll('.owner-group').forEach(function (wrap) {
|
||||
var body = wrap.querySelector('.owner-grid');
|
||||
Array.prototype.slice.call(body.querySelectorAll('.project-card'))
|
||||
.forEach(function (c) { grid.appendChild(c); });
|
||||
wrap.remove();
|
||||
});
|
||||
}
|
||||
function applyGrouping() {
|
||||
var on = !!(groupToggle && groupToggle.checked);
|
||||
lsSet(LS_GROUP, on ? '1' : '0');
|
||||
grids.forEach(function (grid) {
|
||||
ungroupGrid(grid);
|
||||
if (on) groupGrid(grid);
|
||||
});
|
||||
applySort();
|
||||
}
|
||||
|
||||
if (sortSelect) {
|
||||
sortSelect.value = lsGet(LS_SORT, 'default');
|
||||
sortSelect.addEventListener('change', applySort);
|
||||
}
|
||||
if (groupToggle) {
|
||||
groupToggle.checked = lsGet(LS_GROUP, '0') === '1';
|
||||
groupToggle.addEventListener('change', applyGrouping);
|
||||
}
|
||||
applyGrouping(); // applique regroupement + tri mémorisés
|
||||
|
||||
/* ── Menu ⋯ d'actions rapides (#23) ────────────────────── */
|
||||
function closeMenus() {
|
||||
document.querySelectorAll('.card-menu').forEach(function (m) {
|
||||
m.hidden = true;
|
||||
});
|
||||
document.querySelectorAll('[data-menu-btn]').forEach(function (b) {
|
||||
b.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('[data-menu-btn]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var menu = btn.parentElement.querySelector('.card-menu');
|
||||
var willOpen = menu.hidden;
|
||||
closeMenus();
|
||||
if (willOpen) {
|
||||
menu.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', closeMenus);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeMenus();
|
||||
});
|
||||
|
||||
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
|
||||
partagé avec /forecast et /health (0.19.0). */
|
||||
})();
|
||||
@@ -5,6 +5,15 @@
|
||||
(function () {
|
||||
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
|
||||
/* Project switcher (0.19.0): jump between projects without
|
||||
going back to the health index. */
|
||||
var switcher = document.getElementById('health-switch');
|
||||
if (switcher) {
|
||||
switcher.addEventListener('change', function () {
|
||||
window.location.href = '/health/' + switcher.value;
|
||||
});
|
||||
}
|
||||
|
||||
function post(url, fields) {
|
||||
var data = new FormData();
|
||||
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
|
||||
|
||||
+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);
|
||||
})();
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Kanbante — tri des tableaux « pilotage » (.proj-table), partagé
|
||||
* entre le dashboard, /forecast et /health (0.19.0).
|
||||
* Tri client par colonne : les lignes portent leurs valeurs dans
|
||||
* des attributs data-<clé> ; cliquer un <th data-sort="clé">
|
||||
* trie dans un sens puis dans l'autre.
|
||||
*/
|
||||
(function () {
|
||||
document.querySelectorAll('.proj-table').forEach(function (table) {
|
||||
var tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
table.querySelectorAll('th[data-sort]').forEach(function (th) {
|
||||
th.classList.add('sortable');
|
||||
th.addEventListener('click', function () {
|
||||
var key = th.getAttribute('data-sort');
|
||||
// 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');
|
||||
});
|
||||
th.setAttribute('data-dir', dir);
|
||||
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
|
||||
rows.sort(function (a, b) {
|
||||
var va = a.getAttribute('data-' + key) || '';
|
||||
var vb = b.getAttribute('data-' + key) || '';
|
||||
var cmp;
|
||||
// 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);
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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,74 @@
|
||||
"""Palier 14 (0.18.0): vague 2 suite — activité 7 jours (#19),
|
||||
tri/regroupement (#21), vue liste dense (#22), actions rapides (#23)."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import models
|
||||
|
||||
|
||||
def _ts(days_ago: int) -> str:
|
||||
dt = datetime.now(timezone.utc) - timedelta(days=days_ago)
|
||||
return dt.strftime("%Y-%m-%d 12:00:00")
|
||||
|
||||
|
||||
def test_activity_line_recent_week(client, fake_gitea):
|
||||
for _ in range(2):
|
||||
models.log_event("Projets/demo", 1, "closed",
|
||||
to_column="done", created_at=_ts(1))
|
||||
models.log_event("Projets/demo", 5, "moved",
|
||||
from_column="backlog", to_column="todo",
|
||||
created_at=_ts(0))
|
||||
page = client.get("/")
|
||||
assert "2 fermée(s) · 1 déplacement(s) cette semaine" in page.text
|
||||
|
||||
|
||||
def test_activity_line_idle_project(client, fake_gitea):
|
||||
models.log_event("Projets/vieux", 9, "moved",
|
||||
from_column="backlog", to_column="todo",
|
||||
created_at=_ts(35))
|
||||
page = client.get("/")
|
||||
assert "aucune activité kanban depuis 35 j" in page.text
|
||||
|
||||
|
||||
def test_dashboard_display_tools_markup(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert "dashState" in page.text
|
||||
assert 'id="dash-sort"' in page.text
|
||||
assert 'id="dash-group"' in page.text
|
||||
assert "Grouper par propriétaire" in page.text
|
||||
assert "setView('grid')" in page.text
|
||||
assert "setView('list')" in page.text
|
||||
|
||||
|
||||
def test_dense_list_view_tables(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert page.text.count('class="proj-table"') == 3
|
||||
assert 'data-sort="open"' in page.text
|
||||
assert 'data-sort="inflight"' in page.text
|
||||
# La ligne dense de demo : lien page projet + compteurs.
|
||||
assert 'href="/project/Projets/demo"' in page.text
|
||||
|
||||
|
||||
def test_dashboard_js_contract(client, fake_gitea):
|
||||
js = client.get("/static/js/dashboard.js")
|
||||
assert js.status_code == 200
|
||||
assert "kanbante-dash-sort" in js.text
|
||||
assert "kanbante-dash-group" in js.text
|
||||
assert "owner-group" in js.text
|
||||
assert "data-menu-btn" in js.text
|
||||
|
||||
|
||||
def test_card_quick_actions_menu(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert "data-menu-btn" in page.text
|
||||
# urlencode de Jinja conserve les « / » : le lien est valide tel quel.
|
||||
assert 'href="/capture?repo=Projets/demo"' in page.text
|
||||
assert 'href="/triage/Projets/demo"' in page.text
|
||||
assert 'href="/sprint/Projets/demo"' in page.text
|
||||
assert 'href="/health/Projets/demo"' in page.text
|
||||
|
||||
|
||||
def test_capture_prefilled_repo(client, fake_gitea):
|
||||
page = client.get("/capture?repo=Projets/kanbante")
|
||||
assert page.status_code == 200
|
||||
assert 'value="Projets/kanbante"' in page.text
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Palier 15 (0.19.0) — Performance + navigation Pilotage.
|
||||
|
||||
- A : les boucles par dépôt sont parallélisées (le comportement
|
||||
visible ne change pas — couvert par les paliers précédents) et
|
||||
le cache du client passe en stale-while-revalidate (testé dans
|
||||
test_gitea_client.py).
|
||||
- C : /forecast devient un tableau de bord des jalons (une ligne
|
||||
par jalon, ancres depuis le dashboard) et /health un tableau
|
||||
trié par sévérité, avec sélecteur de projet sur le détail.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
|
||||
def test_forecast_is_a_portfolio_table(client):
|
||||
r = client.get("/forecast")
|
||||
assert r.status_code == 200
|
||||
assert "pilot-table" in r.text
|
||||
assert "jalon(s) ouvert(s)" in r.text
|
||||
# One row for the demo milestone, anchored for dashboard links.
|
||||
assert 'id="ms-Projets-demo"' in r.text
|
||||
assert "Sprint 1" in r.text
|
||||
# The per-project pace table is still there.
|
||||
assert "Débit par projet" in r.text
|
||||
|
||||
|
||||
def test_dashboard_milestone_links_into_forecast(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert 'href="/forecast#ms-Projets-demo"' in r.text
|
||||
|
||||
|
||||
def test_health_index_is_a_severity_table(client):
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert "pilot-table" in r.text
|
||||
assert "data-severity" in r.text
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_health_detail_has_project_switcher(client):
|
||||
r = client.get("/health/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert 'id="health-switch"' in r.text
|
||||
assert '<option value="Projets/demo" selected>' in r.text
|
||||
assert "tous les projets" in r.text
|
||||
|
||||
|
||||
async def test_swr_serves_stale_then_refreshes_in_background():
|
||||
"""Stale entries are served instantly; the refresh lands
|
||||
asynchronously and the next read sees the fresh value."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
|
||||
|
||||
client = GiteaClient(
|
||||
base_url="http://gitea.test",
|
||||
token="",
|
||||
cache_ttl=60,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
first = await client.search_repos()
|
||||
assert first == [{"id": 1}]
|
||||
# Fresh cache: no new fetch.
|
||||
assert await client.search_repos() == [{"id": 1}]
|
||||
assert calls["n"] == 1
|
||||
# Age the entry beyond the TTL: the stale value comes back
|
||||
# immediately (same content, no wait)…
|
||||
ts, value = client._cache["search_repos"]
|
||||
client._cache["search_repos"] = (ts - 120, value)
|
||||
assert await client.search_repos() == [{"id": 1}]
|
||||
# …and the background refresh replaces it.
|
||||
await asyncio.sleep(0.3)
|
||||
assert await client.search_repos() == [{"id": 2}]
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_swr_mutation_clear_blocks_stale_resurrection():
|
||||
"""A refresh started before a cache clear (mutation) must not
|
||||
put its now-stale result back."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
|
||||
|
||||
client = GiteaClient(
|
||||
base_url="http://gitea.test",
|
||||
token="",
|
||||
cache_ttl=60,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
await client.search_repos()
|
||||
ts, value = client._cache["search_repos"]
|
||||
client._cache["search_repos"] = (ts - 120, value)
|
||||
await client.search_repos() # triggers background refresh
|
||||
client._cache_clear() # a mutation lands first
|
||||
await asyncio.sleep(0.3)
|
||||
# The cache was cleared and the in-flight refresh was disarmed:
|
||||
# the next read fetches fresh instead of serving the old value.
|
||||
assert await client.search_repos() == [{"id": 3}]
|
||||
await client.aclose()
|
||||
@@ -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