From 48d601176d0f0d8128fc7e1e6f6ad88cc7af7189 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Sun, 3 May 2026 07:57:01 +0200 Subject: [PATCH] admin/licenses : per-machine view with individual remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New action `remove_machine` releasing a single (license_id, machine_id) slot. - Pre-fetches all machines in one query and groups by license_id (no N+1). - Machines column "X / Y" is now clickable: opens an inline expandable row showing each machine on that license — truncated SHA-256 ID with full hash in tooltip, machine_label, first_seen, last_seen with a "stale" warning badge for slots not seen in >30 days, plus a per-row "Libérer" button. - Existing "Libérer machines" button kept but renamed "Libérer toutes" with a beefier confirmation that hints at the per-row alternative. Replaces the all-or-nothing reset workflow with surgical control: when one user changed PCs you can free their old slot without touching their colleagues'. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/admin/licenses.php | 111 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/server/admin/licenses.php b/server/admin/licenses.php index 8a586cc..c800d74 100644 --- a/server/admin/licenses.php +++ b/server/admin/licenses.php @@ -78,7 +78,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { elseif ($action === 'reset_machines') { $id = (int)($_POST['id'] ?? 0); $db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]); - $message = "Machines libérées pour la license #{$id}."; + $message = "Toutes les machines libérées pour la license #{$id}."; + } + elseif ($action === 'remove_machine') { + $licenseId = (int)($_POST['license_id'] ?? 0); + $machineId = trim((string)($_POST['machine_id'] ?? '')); + if ($licenseId <= 0 || $machineId === '') { + throw new Exception('license_id et machine_id requis'); + } + $stmt = $db->prepare('DELETE FROM license_machines WHERE license_id = ? AND machine_id = ?'); + $stmt->execute([$licenseId, $machineId]); + $count = $stmt->rowCount(); + $shortId = substr($machineId, 0, 12); + $message = $count > 0 + ? "Machine {$shortId}… libérée pour la license #{$licenseId}." + : "Aucune machine correspondante trouvée pour license #{$licenseId}."; } } catch (Exception $e) { $message = $e->getMessage(); @@ -93,6 +107,18 @@ $licenses = $db->query( ORDER BY l.id DESC' )->fetchAll(); +// Pré-fetch toutes les machines de toutes les licenses en une seule requête, +// indexées par license_id pour l'affichage par-row sans N+1. +$allMachines = $db->query( + 'SELECT license_id, machine_id, machine_label, first_seen, last_seen + FROM license_machines + ORDER BY last_seen DESC' +)->fetchAll(); +$machinesByLicense = []; +foreach ($allMachines as $m) { + $machinesByLicense[(int)$m['license_id']][] = $m; +} + Layout::header('Licenses', 'licenses'); ?>

Licenses

@@ -165,7 +191,17 @@ Layout::header('Licenses', 'licenses'); - / + + 0): ?> + + / ▾ + + + 0 / + +
@@ -179,11 +215,11 @@ Layout::header('Licenses', 'licenses');
0): ?> -
+ - +
@@ -203,6 +239,73 @@ Layout::header('Licenses', 'licenses'); + + est piloté par le clic sur la cellule "X / Y" ci-dessus. + $machines = $machinesByLicense[(int)$l['id']] ?? []; + if (!empty($machines)): + ?> + + +
+ +
+
+ Machines actives sur la license # () : +
+ + + + + + + + + + + + + + + + + + + + + +
Machine ID (SHA-256)Label1ère activationDernière vueAction
+ + + + + + + stale + + +
+ + + + + +
+
+
+
+ + + Aucune license émise.