Tableau de bord GLPI

Outil interne Laravel qui interroge une base GLPI en lecture seule : indicateurs filtrables, délais de traitement en heures ouvrées, export CSV et avis clients recueillis depuis l'e-mail de clôture.

Présentation

J'ai construit un outil interne pour une équipe de support informatique qui suit ses tickets dans GLPI : une application Laravel 12 qui lit la base MySQL de GLPI sans jamais y écrire. Elle en tire un tableau de bord : nombre de tickets, temps passé, délai de traitement, classements par catégorie, demandeur et entité, évolution mensuelle. Dessous, un tableau paginé et triable liste les tickets concernés, avec un export CSV et une fiche par entité, catégorie ou demandeur. Un second module recueille l'avis des clients à la clôture d'un ticket.

Les chiffres et la liste sortent de la même requête

Tout ce qui lit des tickets passe par TicketQuery::base(), alimenté par un objet FilterCriteria. Les indicateurs de StatsService et le tableau partent de cette même base : un chiffre ne peut pas porter sur un autre ensemble que la liste qu'il résume.

Plusieurs pièges de GLPI se règlent à cet endroit. Un scope global sur le modèle Ticket écarte la corbeille, pour qu'aucune requête future ne l'oublie. La jointure sur les catégories est un LEFT JOIN, sinon les tickets sans catégorie disparaîtraient des compteurs. Pour filtrer une entité et ses sous-entités, j'utilise completename, qui contient déjà le chemin hiérarchique : un LIKE sur le préfixe, une fois % et _ neutralisés, récupère la branche sans requête récursive. Le filtre par demandeur passe par un WHERE EXISTS, pour qu'un ticket à deux demandeurs ne s'affiche pas deux fois. Le tri n'accepte que les colonnes de la liste blanche TRIABLES, puisque sa valeur vient de l'URL.

Côté navigateur, Alpine.js rappelle /donnees 250 ms après le dernier changement de filtre et reporte les filtres dans l'URL, ce qui rend une vue filtrée partageable.

Filtré sur l'entité Client A et le type Incident, le tableau inclut les tickets de ses sous-entités (Siège, agences). Un clic sur une ligne déplie sa description.

Un délai compté en heures ouvrées

Un ticket ouvert le vendredi à 17h et résolu le lundi à 9h n'a pas demandé 64 heures de traitement. La classe BusinessHours compte le temps ouvré entre deux dates à partir de plages horaires configurées, hors week-ends, jours fériés français et fermetures exceptionnelles. GLPI ne stocke aucun férié : je les calcule, Pâques compris, avec l'algorithme de Meeus/Jones/Butcher écrit à la main pour ne pas dépendre de l'extension PHP calendar. Les jours pleins se comptent par semaines entières : même sur un ticket resté ouvert un an, six jours au plus sont parcourus un à un, puis les fériés sont retirés :

$jours = (int) $d1->diffInDays($d2) + 1;
$semaines = intdiv($jours, 7);
$reste = $jours % 7;
$total = $semaines * array_sum($this->secondesParJour);
for ($i = 0; $i < $reste; $i++) {
    $jour = $d1->addDays($semaines * 7 + $i);
    $total += $this->secondesParJour[$jour->dayOfWeekIso] ?? 0;
}
foreach ($this->feriesEntre($d1, $d2) as $ferie) {
    $total -= $this->secondesParJour[$ferie->dayOfWeekIso] ?? 0;
}

J'ai gardé ce calcul en PHP : exprimé en SQL, il serait illisible et intestable. Contrepartie : le délai ne se trie pas. Les tests unitaires couvrent la pause de midi, le week-end, les fériés fixes et mobiles, et un ticket ouvert un an. Le tableau de bord affiche la médiane à côté de la moyenne, car un seul ticket oublié suffit à fausser la moyenne. Une colonne donne aussi le délai hors attente du client.

Le tableau de bord sur une base GLPI de test : le délai médian en heures ouvrées (3h33) reste loin de la moyenne (14h14), que quelques tickets oubliés tirent vers le haut.

Une base de production qu'on ne peut que lire

Le compte MySQL n'a que le droit SELECT, et GlpiModel double cette protection : save, update, delete et forceDelete y lèvent une exception explicite. Les comptes et les avis vivent dans une base applicative séparée. GLPI stocke ses libellés encodés en HTML, parfois deux fois : GlpiText::propre les décode. Si GLPI ne répond pas, la page le dit au lieu de renvoyer une erreur 500.

Recueillir un avis depuis un e-mail

Le modèle de notification que j'ai écrit pour GLPI affiche cinq étoiles, chacune étant un lien qui porte la note, car un e-mail ne peut pas contenir de formulaire fiable. La note est enregistrée dès l'arrivée sur la page d'avis, puis la page propose de la valider avec un commentaire. Certains filtres de sécurité de messagerie visitent les liens avant le destinataire : la table avis garde donc un indicateur confirme, et une note différente n'en remplace une autre qu'après confirmation. AvisService fusionne ces avis avec ceux de l'enquête native de GLPI.

Le lien de vote porte une note de 4/5 pour un ticket déjà noté 1/5 : la page demande une confirmation avant de remplacer l'ancienne note.

Tout le reste passe par Laravel Fortify. L'inscription est désactivée, les comptes se créent par une commande Artisan ou une page interne, avec un mot de passe d'au moins 12 caractères. Le middleware ExigerDoubleFacteur renvoie vers la configuration TOTP tant que le second facteur n'est pas confirmé, et un compte désactivé reçoit le même message qu'un mauvais mot de passe. Des tests de fonctionnalité vérifient cette frontière.

Ce que je reprendrais

Le vote part d'un e-mail généré par GLPI, ce qui limite ce que le lien peut porter. Je générerais plutôt ces liens depuis l'application elle-même, pour mieux contrôler ce qui arrive sur la page d'avis. Le filtre par entité mériterait aussi d'être plus strict : son préfixe peut attraper une entité voisine dont le nom commence pareil, et il faudrait tester le nom exact ou le préfixe suivi du séparateur >. Enfin, le délai hors attente reste approximatif, car GLPI ne stocke que la durée totale d'attente, en temps calendaire.