admin/licenses : per-machine view with individual remove
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
?>
|
||||
<h1>Licenses</h1>
|
||||
@@ -165,7 +191,17 @@ Layout::header('Licenses', 'licenses');
|
||||
</td>
|
||||
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
|
||||
<td><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></td>
|
||||
<td><?= $l['machines_count'] ?> / <?= $l['max_machines'] ?></td>
|
||||
<td>
|
||||
<?php if ($l['machines_count'] > 0): ?>
|
||||
<a href="#" onclick="document.getElementById('machines-<?= $l['id'] ?>').open = !document.getElementById('machines-<?= $l['id'] ?>').open; return false;"
|
||||
style="color: var(--text); text-decoration: none; cursor: pointer;"
|
||||
title="Cliquer pour voir les machines">
|
||||
<strong><?= $l['machines_count'] ?></strong> / <?= $l['max_machines'] ?> ▾
|
||||
</a>
|
||||
<?php else: ?>
|
||||
0 / <?= $l['max_machines'] ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
|
||||
<td style="text-align:right; white-space: nowrap;">
|
||||
<details style="display: inline-block; margin: 0 4px;">
|
||||
@@ -179,11 +215,11 @@ Layout::header('Licenses', 'licenses');
|
||||
</form>
|
||||
</details>
|
||||
<?php if ($l['machines_count'] > 0): ?>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Libérer toutes les machines de cette license ?')">
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="reset_machines">
|
||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||
<button class="btn btn-secondary" type="submit" title="Libère les slots machines occupés">Libérer machines</button>
|
||||
<button class="btn btn-secondary" type="submit" title="Libère TOUS les slots machines de cette license d'un coup">Libérer toutes</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php if (!$isRevoked): ?>
|
||||
@@ -203,6 +239,73 @@ Layout::header('Licenses', 'licenses');
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<?php
|
||||
// Sous-row dépliable : liste des machines avec remove individuel.
|
||||
// Le <details> est piloté par le clic sur la cellule "X / Y" ci-dessus.
|
||||
$machines = $machinesByLicense[(int)$l['id']] ?? [];
|
||||
if (!empty($machines)):
|
||||
?>
|
||||
<tr>
|
||||
<td colspan="7" style="padding: 0;">
|
||||
<details id="machines-<?= $l['id'] ?>" style="margin: 0;">
|
||||
<summary style="display: none;"></summary>
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 12px 16px; border-top: 1px solid var(--border);">
|
||||
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">
|
||||
Machines actives sur la license #<?= $l['id'] ?> (<?= htmlspecialchars($l['owner_name']) ?>) :
|
||||
</div>
|
||||
<table style="width: 100%; font-size: 12px; margin: 0;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 280px;">Machine ID (SHA-256)</th>
|
||||
<th>Label</th>
|
||||
<th style="width: 130px;">1ère activation</th>
|
||||
<th style="width: 130px;">Dernière vue</th>
|
||||
<th style="width: 110px; text-align:right;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($machines as $m):
|
||||
$mid = $m['machine_id'];
|
||||
$isStale = strtotime($m['last_seen']) < time() - 86400 * 30;
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<code title="<?= htmlspecialchars($mid) ?>"
|
||||
style="font-family: 'Cascadia Code', Consolas, monospace;">
|
||||
<?= htmlspecialchars(substr($mid, 0, 16)) ?>…<?= htmlspecialchars(substr($mid, -8)) ?>
|
||||
</code>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($m['machine_label'] ?? '—') ?></td>
|
||||
<td class="muted"><?= date('d/m/Y H:i', strtotime($m['first_seen'])) ?></td>
|
||||
<td class="muted">
|
||||
<?= date('d/m/Y H:i', strtotime($m['last_seen'])) ?>
|
||||
<?php if ($isStale): ?>
|
||||
<span class="badge badge-warning" title="Pas vu depuis > 30 jours, candidat à libération">stale</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<form method="post" style="display:inline"
|
||||
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
|
||||
<?= Layout::csrfField() ?>
|
||||
<input type="hidden" name="action" value="remove_machine">
|
||||
<input type="hidden" name="license_id" value="<?= $l['id'] ?>">
|
||||
<input type="hidden" name="machine_id" value="<?= htmlspecialchars($mid) ?>">
|
||||
<button class="btn btn-danger" type="submit"
|
||||
style="font-size: 11px; padding: 4px 10px;">
|
||||
Libérer
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($licenses)): ?>
|
||||
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune license émise.</td></tr>
|
||||
|
||||
Reference in New Issue
Block a user