Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ Navigation entre pages : rendu serveur classique (`PageController`, GET, templat
- **Où** : `src/Backup/` (le code de production vit dans `src/`, `tools/` n'est pas déployé). `DatabaseBackup` orchestre : dump provisoire → `DumpVerifier` (gzip valide, `CREATE TABLE`, ligne `-- Dump completed`, lecture en flux) → renommage atomique → `BackupRetention` (garde les N plus récents d'UN type, jamais un fichier inconnu ni `.partial`, ordre par l'horodatage du nom, minimum 1). `ProcessDumper` lance `mariadb-dump` sans shell, identifiants par un fichier d'options temporaire 0600 (jamais en argument ni dans une erreur), sortie compressée au fil de l'eau. Façade CLI : `bin/backup-db.php` ; copie locale : `bin/fetch-backup.sh`.
- **Deux types** : `daily` (`db-<UTC>.sql.gz`, 14) et `pre-deploy` (`pre-<release>.sql.gz`, 7, appelé par `bin/deploy.sh`). Procédure complète (cron, copie hors serveur, restauration, clé) : `.claude/deploiement.md`.
- **Restauration (#241)** : `src/Backup/Restore/`. `DatabaseRestore` : confirmation = nom exact de la base → verrou (`RestoreStatus::acquire`, flock) → dump dans le catalogue (`BackupCatalog`, nom exact, jamais un chemin) et `DumpVerifier` → **essai à blanc** dans la base temporaire (`SchemaTools`, vide exigée, vidée ensuite) → dump de l'état actuel (`pre-<UTC>-avant-restauration`) → suppression des tables de la production → `ProcessImporter` (`mariadb` sans shell, même `ClientOptions` que le dump) → contrôle du nombre de tables → suites (`bin/restore-db.php` : migrations, contrôle des clés, rapport `DocumentOrphanReport` en lecture seule). L'état (`restore-status.json`) vit dans un FICHIER (`restore.state_dir`) : la base est remplacée pendant l'opération. Deux entrées : `bin/restore-db.php` (CLI) et la page `/admin/restore` (`RestoreController`, **propriétaire seulement** = `metrics.viewer_email`, 404 pour les autres ; mot de passe actuel + mot « RESTAURER » ; `RestoreLauncher` lance le script en processus détaché via `setsid`). Réglages `restore.*` dans `config.local.php` (dossier des sauvegardes, base temporaire, programme PHP).
- **Exploitation (#375)** : `src/Operations/`, lecture seule, pour le propriétaire (page `GET /admin/ops`, `Controller/OpsPageController`, même 404 uniforme que les mesures ; cartes partagées avec les mesures dans `templates/admin/metrics/_cards.php` ; le lien « Ops » de la barre du bas n'apparaît que pour ce propriétaire : `View/OpsLinkRenderer` décore le renderer et ajoute `showOpsLink` aux gabarits d'administrateur). `OpsPaths` (noms fixes sous `ops.base_dir`, jamais un paramètre de requête), `BoundedFile` (lien symbolique refusé, fin de fichier bornée à 64 Ko, jamais d'exception), `Backup/BackupHealthBuilder` (dumps : le quotidien se juge sur les `db-*` seuls, rétention, restauration, base temporaire), `Backup/ProtectionHealthBuilder` (clé via `KeyFileProbe` : un état et un nombre, jamais une valeur ; tâches planifiées d'après la date de leur journal ; copie hors serveur et sauvegarde de la clé via `Declarations`, écrites seulement par `bin/ops-declare.php`). Onglet Production (`GET /admin/ops/production`, `Production/`) : `DeploymentLog` (lit `shared/deployments.jsonl`, 64 Ko de fin, motifs stricts), `ReleaseInventory` (releases comme `rollback.sh` les voit, cible du retour arrière, marqueur `RELEASE`), `MigrationStatus` (journal des migrations, null si illisible), `ProductionReportBuilder` (cartes et durées). Alertes : `Backup/OpsAlerts` fournit à `AlertEvaluator` (paramètre optionnel `$extra`) les alertes rouges de clé, de tâche de sauvegarde et de copie hors serveur ; `Alert::$page` choisit le lien de l'e-mail (mesures ou exploitation), phrases fixes sans chemin ni nom de clé.
- **Exploitation (#375)** : `src/Operations/`, lecture seule, pour **tout administrateur actif** depuis le 2026-10-17 (`OperationsAccess` ; avant, le seul propriétaire ; page `GET /admin/ops`, `Controller/OpsPageController`, même 404 uniforme que les mesures ; cartes partagées avec les mesures dans `templates/admin/metrics/_cards.php` ; le lien « Ops » apparaît pour tout administrateur actif, le lien « Restauration » (`showRestoreLink`) pour le seul propriétaire (`MetricsAccess`) : `View/OpsLinkRenderer` décore le renderer et ajoute `showOpsLink` aux gabarits d'administrateur). `OpsPaths` (noms fixes sous `ops.base_dir`, jamais un paramètre de requête), `BoundedFile` (lien symbolique refusé, fin de fichier bornée à 64 Ko, jamais d'exception), `Backup/BackupHealthBuilder` (dumps : le quotidien se juge sur les `db-*` seuls, rétention, restauration, base temporaire), `Backup/ProtectionHealthBuilder` (clé via `KeyFileProbe` : un état et un nombre, jamais une valeur ; tâches planifiées d'après la date de leur journal ; copie hors serveur et sauvegarde de la clé via `Declarations`, écrites seulement par `bin/ops-declare.php`). Onglet Production (`GET /admin/ops/production`, `Production/`) : `DeploymentLog` (lit `shared/deployments.jsonl`, 64 Ko de fin, motifs stricts), `ReleaseInventory` (releases comme `rollback.sh` les voit, cible du retour arrière, marqueur `RELEASE`), `MigrationStatus` (journal des migrations, null si illisible), `ProductionReportBuilder` (cartes et durées). Alertes : `Backup/OpsAlerts` fournit à `AlertEvaluator` (paramètre optionnel `$extra`) les alertes rouges de clé, de tâche de sauvegarde et de copie hors serveur ; `Alert::$page` choisit le lien de l'e-mail (mesures ou exploitation), phrases fixes sans chemin ni nom de clé.
- **Pièges vus** : une `Closure` est exigée par `DatabaseBackup` (le script passe `->__invoke(...)`) ; une erreur de `mariadb-dump` n'est jamais relayée (elle nomme l'utilisateur) ; la rotation ne s'exécute qu'après un dump vérifié.

## Sourdine par conversation (#210)
Expand Down Expand Up @@ -401,15 +401,15 @@ La pastille d'initiales est un **gabarit partiel** (`templates/partials/avatar.p

## Tableau de bord des mesures : page « Santé et e-mails » (#196)

`GET /admin/metrics?periode=24h|7j|30j` (`Metrics/Controller/MetricsPageController`), page rendue par le serveur, **sans JavaScript** : la période se choisit par un lien. **Réservée à un seul compte** (`MetricsAccess`, adresse `metrics.viewer_email` de `config.local.php`, qui doit aussi être un administrateur actif) : pour tous les autres (visiteur, musicien, **autre administrateur**, ou adresse non configurée) la réponse est le même **404** qu'une route inexistante, sans indice.
`GET /admin/metrics?periode=24h|7j|30j` (`Metrics/Controller/MetricsPageController`), page rendue par le serveur, **sans JavaScript** : la période se choisit par un lien. **Ouverte à tout administrateur actif depuis le 2026-10-17** (`OperationsAccess` ; auparavant un seul compte, `MetricsAccess` et `metrics.viewer_email`, qui restent pour les alertes e-mail et la restauration ; ancienne règle : un administrateur actif) : pour tous les autres (visiteur, musicien, **autre administrateur**, ou adresse non configurée) la réponse est le même **404** qu'une route inexistante, sans indice.

- `Metrics/Report/` : `MysqlMetricsReader` (lecture, séparée de l'écriture de la collecte), `HealthReportBuilder` (regroupe en points locaux : une heure sur 24 h, un jour sur 7 et 30 jours ; calcule disponibilité, 5xx, temps moyen, e-mails, cron, sauvegarde, disque, base, version), `Thresholds` (seuils orange / rouge par mesure, surchargeables dans `metrics.thresholds`), `HealthStatus` (toujours icône **et** libellé : jamais la couleur seule), `StatusCard`.
- `Metrics/Chart/SvgChart` : barres et courbe en SVG rendus par PHP, **sans bibliothèque**. Une couleur par graphique (jetons du thème en classes CSS `.rb-chart-*`, rien en dur), barres ≤ 24 px à bout arrondi, courbe de 2 px, grille discrète, axe unique aux valeurs rondes (1, 2, 5 × 10ᵏ), au plus 5 étiquettes d'abscisse ; chaque graphique a un titre, un résumé lu par les lecteurs d'écran et un **tableau de valeurs** repliable ; une série vide ou à une seule valeur ne casse pas le dessin. Tout texte est échappé.
- Pas encore : l'indicateur « adresses de contact invalides » (ces cas ne sont aujourd'hui que dans le journal, pas dans les mesures).

## Tableau de bord des mesures : sécurité et anomalies (#197)

`GET /admin/metrics/security?periode=…` (même contrôleur, même règle d'accès à **un seul compte**, onglets « Santé et e-mails » / « Sécurité » dans `templates/admin/metrics/_header.php`). **On rapporte, on ne bloque rien.**
`GET /admin/metrics/security?periode=…` (même contrôleur, même règle d'accès : **tout administrateur actif**, onglets « Santé et e-mails » / « Sécurité » dans `templates/admin/metrics/_header.php`). **On rapporte, on ne bloque rien.**

- **Compteurs et graphiques** par heure (24 h) ou par jour : échecs de connexion, accès refusés (403), jetons CSRF refusés, limites de débit, demandes de mot de passe oublié (évènement ajouté à `EventClassifier`), pages introuvables dont celles sur des chemins de balayage. `TimeBuckets` (extrait de la page santé, partagé) fabrique les points en heure locale.
- **Détection** (`Report/Security/AnomalyDetector`, fonction pure, testée avec des séries simulées, seuils réglables dans `metrics.anomalies`) par **empreinte d'adresse** : *scanner* (≥ 3 pages introuvables sur des chemins de balayage, `ScannerPaths`), *rafale* (≥ 30 évènements de sécurité en 5 min), *échecs de connexion répétés* (≥ 10 en 60 min). Sans empreinte (secret non configuré) les évènements sont regroupés sous « — ». L'empreinte est affichée **tronquée à 8 caractères**, jamais une adresse.
Expand All @@ -418,7 +418,7 @@ La pastille d'initiales est un **gabarit partiel** (`templates/partials/avatar.p

## Tableau de bord des mesures : charge et dégradation (#198)

`GET /admin/metrics/load?periode=…` (même contrôleur, même règle d'accès à un seul compte, onglet « Charge »).
`GET /admin/metrics/load?periode=…` (même contrôleur, même règle d'accès : tout administrateur actif, onglet « Charge »).

- **Médiane et 95e centile sans garder les durées une par une** : migration 032, six colonnes de répartition dans `metric_hourly` (≤ 50, 100, 250, 500, 1000 ms, au-delà). `DurationHistogram` classe chaque durée et estime un centile par la **borne haute de la tranche** qui le contient (« au plus 250 ms » ; au-delà d'une seconde : « > 1000 ms »). Les lignes collectées avant la migration n'ont pas de répartition : leurs centiles sont inconnus.
- **Page** : requêtes par minute (moyenne et pic), médiane et 95e centile, pic de mémoire PHP, **routes les plus sollicitées** avec leur part du temps total et le **polling de la messagerie** mis en évidence (`/api/conversations/{id}/updates`, premier suspect si le trafic grossit).
Expand Down
4 changes: 2 additions & 2 deletions .claude/deploiement.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ Le journal ne contient que des noms de fichiers. Un code de sortie 1 (« Sauvega

**Copie hors serveur** : `RB_SSH_CONFIG=<chemin> ./bin/fetch-backup.sh` (depuis le poste de dev) rapatrie le dernier dump quotidien (`--pre` : le dernier d'avant déploiement) dans `~/rehearsalbox-backups` (0700, **jamais dans le dépôt**, vérifié par `gzip -t`, 30 gardés ; `RB_BACKUP_LOCAL_DIR` / `RB_BACKUP_LOCAL_KEEP` pour changer). À lancer régulièrement : seule une copie hors du serveur protège d'une perte du compte d'hébergement.

**Page Exploitation (#375)** : `/admin/ops` (onglet Sauvegardes), réservée au propriétaire (`metrics.viewer_email`, 404 pour tous les autres). Réglage à poser UNE fois dans `shared/config.local.php` : `'ops' => ['base_dir' => '/home/<compte>/rehearsalbox']` (le dossier qui contient `shared/` et `releases/`) ; sans lui, les indicateurs de tâches et de déclarations sont « inconnus ». Elle réutilise `restore.backup_dir`, `restore.scratch_schema` et `messages.key_file`.
**Page Exploitation (#375)** : `/admin/ops` (onglet Sauvegardes), ouverte à tout administrateur actif depuis le 2026-10-17 (404 pour les autres ; la page de RESTAURATION reste réservée au propriétaire, `metrics.viewer_email`). Réglage à poser UNE fois dans `shared/config.local.php` : `'ops' => ['base_dir' => '/home/<compte>/rehearsalbox']` (le dossier qui contient `shared/` et `releases/`) ; sans lui, les indicateurs de tâches et de déclarations sont « inconnus ». Elle réutilise `restore.backup_dir`, `restore.scratch_schema` et `messages.key_file`.

**Onglet Production (#397)** : `/admin/ops/production` (propriétaire seulement) lit le journal des déploiements (64 Ko de fin, lignes invalides ignorées), les releases présentes (`ops.base_dir/releases`, cible du retour arrière calculée comme `bin/rollback.sh`), le marqueur `RELEASE` de la release servie et le journal des migrations. Cartes : release servie, dernier déploiement (réussi ; échoué avant la bascule = production inchangée ; basculé mais contrôle échoué = rouge), migrations en attente, retour arrière, dump d'avant déploiement. Dates du journal à l'heure du poste de dev. L'écart entre `main` et la production n'est pas affiché.
**Onglet Production (#397)** : `/admin/ops/production` (tout administrateur actif) lit le journal des déploiements (64 Ko de fin, lignes invalides ignorées), les releases présentes (`ops.base_dir/releases`, cible du retour arrière calculée comme `bin/rollback.sh`), le marqueur `RELEASE` de la release servie et le journal des migrations. Cartes : release servie, dernier déploiement (réussi ; échoué avant la bascule = production inchangée ; basculé mais contrôle échoué = rouge), migrations en attente, retour arrière, dump d'avant déploiement. Dates du journal à l'heure du poste de dev. L'écart entre `main` et la production n'est pas affiché.

**Journal des déploiements (#396)** : `bin/deploy.sh` note chaque déploiement dans `shared/deployments.jsonl` (0600, sur le serveur, les 200 dernières lignes) : une ligne JSON `{release, commit, startedAt (UTC, heure du poste de dev), durationS, outcome ok|failed, switched, checks ci|local|skipped}`. `switched` vaut faux si le déploiement s'est arrêté avant la bascule (la production n'a pas changé). Le piège `EXIT` est posé une fois la release connue ; chaque champ est validé (`bin/lib/deploy-log.sh`), donc aucune variable d'environnement ni aucun chemin n'y entre, et **un échec d'écriture n'a aucun effet sur le déploiement** (code de sortie conservé, simple avertissement). Un déploiement arrêté avant la création de la release (contrôles locaux en échec) n'est pas journalisé. Le dump d'avant déploiement associé se retrouve par son nom (`pre-…-<release>`). La lecture est pour l'onglet Production (#397).

Expand Down
7 changes: 4 additions & 3 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
use App\Operations\Production\ReleaseInventory;
use App\Operations\View\OpsLinkRenderer;
use App\Metrics\MetricsAccess;
use App\Metrics\OperationsAccess;
use App\Metrics\Report\HealthReportBuilder;
use App\Metrics\Report\MetricsReaderInterface;
use App\Metrics\Report\MysqlMetricsReader;
Expand Down Expand Up @@ -349,7 +350,7 @@
return new MetricsPageController(
$c->get(TemplateRendererInterface::class),
$c->get(AuthGuard::class),
new MetricsAccess((string) $config['metrics']['viewer_email']),
new OperationsAccess(),
new HealthReportBuilder($c->get(MetricsReaderInterface::class), $thresholds, $c->get(ClockInterface::class), $timezone),
new SecurityReportBuilder(
$c->get(MetricsReaderInterface::class),
Expand Down Expand Up @@ -520,7 +521,7 @@
Transport::fromDsn($config['mailer']['dsn']),
));

$container->set(TemplateRendererInterface::class, fn ($c) => new OpsLinkRenderer(new PhpTemplateRenderer(__DIR__ . '/../templates'), $c->get(AuthGuard::class), new MetricsAccess((string) $config['metrics']['viewer_email'])));
$container->set(TemplateRendererInterface::class, fn ($c) => new OpsLinkRenderer(new PhpTemplateRenderer(__DIR__ . '/../templates'), $c->get(AuthGuard::class), new OperationsAccess(), new MetricsAccess((string) $config['metrics']['viewer_email'])));

$container->set(PageController::class, fn ($c) => new PageController(
$c->get(TemplateRendererInterface::class),
Expand Down Expand Up @@ -1408,7 +1409,7 @@
$container->set(OpsPageController::class, fn ($c) => new OpsPageController(
$c->get(TemplateRendererInterface::class),
$c->get(AuthGuard::class),
new MetricsAccess((string) $config['metrics']['viewer_email']),
new OperationsAccess(),
$c->get(BackupHealthBuilder::class),
$c->get(ProtectionHealthBuilder::class),
$c->get(ProductionReportBuilder::class),
Expand Down
8 changes: 4 additions & 4 deletions src/Metrics/Controller/MetricsPageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use App\Http\ErrorPage;
use App\Http\Request;
use App\Http\Response;
use App\Metrics\MetricsAccess;
use App\Metrics\OperationsAccess;
use App\Metrics\Report\HealthReportBuilder;
use App\Metrics\Report\Load\LoadReportBuilder;
use App\Metrics\Report\MetricsPeriod;
Expand All @@ -16,15 +16,15 @@
use App\View\TemplateRendererInterface;

/**
* Tableau de bord des mesures, page « Santé et e-mails » (#196). Réservé à UN compte (MetricsAccess) : pour tous les autres,
* y compris les autres administrateurs et les visiteurs non connectés, la page n'existe pas (404 uniforme, aucun indice).
* Tableau de bord des mesures, page « Santé et e-mails » (#196). Ouvert à tout administrateur ACTIF (OperationsAccess, décision du
* propriétaire du 2026-10-17) : pour tous les autres (visiteur, musicien, administrateur désactivé), la page n'existe pas (404 uniforme, aucun indice).
*/
final class MetricsPageController
{
public function __construct(
private readonly TemplateRendererInterface $renderer,
private readonly AuthGuard $authGuard,
private readonly MetricsAccess $access,
private readonly OperationsAccess $access,
private readonly HealthReportBuilder $reports,
private readonly SecurityReportBuilder $securityReports,
private readonly LoadReportBuilder $loadReports,
Expand Down
22 changes: 22 additions & 0 deletions src/Metrics/OperationsAccess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Metrics;

use App\Account\Entity\User;
use App\Account\Entity\UserRole;

/**
* Qui peut LIRE l'exploitation (sauvegardes, production) et les mesures : tout administrateur ACTIF (décision du propriétaire, 2026-10-17 ;
* avant, un seul compte). Ce sont des pages en lecture seule, sans chemin du serveur, sans clé ni donnée personnelle. Tout autre visiteur
* (anonyme, musicien, administrateur désactivé) reçoit le même 404 uniforme. La RESTAURATION de la base, elle, reste réservée au seul
* propriétaire (MetricsAccess) : elle remplace toute la base.
*/
final class OperationsAccess
{
public function allows(?User $user): bool
{
return $user !== null && $user->isActive() && $user->role() === UserRole::Admin;
}
}
Loading
Loading