diff --git a/.claude/architecture.md b/.claude/architecture.md index 82274da4..c495ebd8 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -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-.sql.gz`, 14) et `pre-deploy` (`pre-.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--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) @@ -401,7 +401,7 @@ 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é. @@ -409,7 +409,7 @@ La pastille d'initiales est un **gabarit partiel** (`templates/partials/avatar.p ## 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. @@ -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). diff --git a/.claude/deploiement.md b/.claude/deploiement.md index 84fe836c..c3c5c3f2 100644 --- a/.claude/deploiement.md +++ b/.claude/deploiement.md @@ -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= ./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//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//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-…-`). La lecture est pour l'onglet Production (#397). diff --git a/config/services.php b/config/services.php index d7ba0947..ac553d92 100644 --- a/config/services.php +++ b/config/services.php @@ -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; @@ -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), @@ -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), @@ -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), diff --git a/src/Metrics/Controller/MetricsPageController.php b/src/Metrics/Controller/MetricsPageController.php index a3adc76f..4b8c1dec 100644 --- a/src/Metrics/Controller/MetricsPageController.php +++ b/src/Metrics/Controller/MetricsPageController.php @@ -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; @@ -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, diff --git a/src/Metrics/OperationsAccess.php b/src/Metrics/OperationsAccess.php new file mode 100644 index 00000000..e89342df --- /dev/null +++ b/src/Metrics/OperationsAccess.php @@ -0,0 +1,22 @@ +isActive() && $user->role() === UserRole::Admin; + } +} diff --git a/src/Operations/Controller/OpsPageController.php b/src/Operations/Controller/OpsPageController.php index 5d1ebce8..31e6bd30 100644 --- a/src/Operations/Controller/OpsPageController.php +++ b/src/Operations/Controller/OpsPageController.php @@ -8,7 +8,7 @@ use App\Http\Request; use App\Http\Response; use App\Metrics\Chart\SvgChart; -use App\Metrics\MetricsAccess; +use App\Metrics\OperationsAccess; use App\Operations\Backup\BackupHealthBuilder; use App\Operations\Backup\ProtectionHealthBuilder; use App\Operations\Production\ProductionReportBuilder; @@ -16,8 +16,8 @@ use App\View\TemplateRendererInterface; /** - * Exploitation (#375) : état de la production, en lecture seule. Réservé au même UNIQUE compte que les mesures (MetricsAccess) : - * pour tous les autres, y compris un autre administrateur, un visiteur ou une adresse non configurée, la page n'existe pas + * Exploitation (#375) : état de la production, en lecture seule. Ouvert à tout administrateur ACTIF (OperationsAccess, décision du + * propriétaire du 2026-10-17) : pour tous les autres, un visiteur, un musicien ou un administrateur désactivé, la page n'existe pas * (404 uniforme, aucun indice) et aucun lecteur n'est interrogé avant ce contrôle. */ final class OpsPageController @@ -25,7 +25,7 @@ final class OpsPageController public function __construct( private readonly TemplateRendererInterface $renderer, private readonly AuthGuard $authGuard, - private readonly MetricsAccess $access, + private readonly OperationsAccess $access, private readonly BackupHealthBuilder $backups, private readonly ProtectionHealthBuilder $protection, private readonly ProductionReportBuilder $production, diff --git a/src/Operations/View/OpsLinkRenderer.php b/src/Operations/View/OpsLinkRenderer.php index b860a6f4..d761f887 100644 --- a/src/Operations/View/OpsLinkRenderer.php +++ b/src/Operations/View/OpsLinkRenderer.php @@ -6,12 +6,14 @@ use App\Account\Entity\UserRole; use App\Metrics\MetricsAccess; +use App\Metrics\OperationsAccess; use App\Security\AuthGuard; use App\View\TemplateRendererInterface; /** - * Ajoute à chaque gabarit d'administrateur l'indicateur `showOpsLink` (#400) : le lien « Ops » de la barre du bas n'est montré qu'au - * propriétaire (MetricsAccess), pas aux autres administrateurs, sans que chaque contrôleur ait à le calculer. Le compte n'est interrogé + * Ajoute à chaque gabarit d'administrateur deux indicateurs, sans que chaque contrôleur ait à les calculer : `showOpsLink` (#400, le lien + * « Ops », pour tout administrateur actif : OperationsAccess) et `showRestoreLink` (le lien « Restauration » de l'exploitation, pour le + * seul propriétaire : MetricsAccess, car elle remplace toute la base). Le compte n'est interrogé * que pour un gabarit qui porte le rôle administrateur ; ce n'est qu'un confort d'affichage, la page reste protégée par son contrôleur. */ final class OpsLinkRenderer implements TemplateRendererInterface @@ -19,14 +21,17 @@ final class OpsLinkRenderer implements TemplateRendererInterface public function __construct( private readonly TemplateRendererInterface $inner, private readonly AuthGuard $authGuard, - private readonly MetricsAccess $access, + private readonly OperationsAccess $operations, + private readonly MetricsAccess $owner, ) { } public function render(string $template, array $data = []): string { if (($data['currentUserRole'] ?? null) === UserRole::Admin) { - $data['showOpsLink'] = $this->access->allows($this->authGuard->currentUserOrNull()); + $user = $this->authGuard->currentUserOrNull(); + $data['showOpsLink'] = $this->operations->allows($user); + $data['showRestoreLink'] = $this->owner->allows($user); } return $this->inner->render($template, $data); diff --git a/templates/admin/ops/_header.php b/templates/admin/ops/_header.php index 73524b92..5c0faa85 100644 --- a/templates/admin/ops/_header.php +++ b/templates/admin/ops/_header.php @@ -3,6 +3,7 @@ * En-tête de l'exploitation (#375) : titre, onglets et liens vers les pages voisines (mesures, restauration). * * @var string $section 'backups' ou 'production' + * @var bool|null $showRestoreLink onglet « Restauration » : propriétaire seulement (OpsLinkRenderer) */ $sections = ['backups' => ['/admin/ops', 'Sauvegardes'], 'production' => ['/admin/ops/production', 'Production']]; ?> @@ -15,5 +16,5 @@ > Mesures - Restauration + Restauration diff --git a/templates/partials/admin-tabs.php b/templates/partials/admin-tabs.php index f04e89ff..1f964ecd 100644 --- a/templates/partials/admin-tabs.php +++ b/templates/partials/admin-tabs.php @@ -4,7 +4,7 @@ * (navigation serveur, simples liens), l'onglet courant est marqué aria-current="page". * * @var string $adminTab 'bookings', 'slots', 'groups', 'users', 'assemblies' ou 'accounting' - * @var bool|null $showOpsLink onglet « Ops » : propriétaire seulement (#400, OpsLinkRenderer) + * @var bool|null $showOpsLink onglet « Ops » : tout administrateur actif (#400, OpsLinkRenderer) */ ?>