Two things change so the operator never has to SSH for a launcher release. 1. SignManifest::run() now also re-hashes the manifest's `launcher` section. It looks for builds/launcher/<basename of launcher.url>; falls back to a tolerant glob *<version>*.exe if the exact name isn't found. Updates sizeBytes + sha256 in place. The Ed25519 sign step at the end already covered this branch — it was just blank data before. 2. admin/versions.php has a new "Auto-update du launcher" card above the manifest table. Shows the announced version + minRequired, the exe presence badge and the hash status, and a small form to set or update the launcher entry (version + minRequired only — URL is derived from base_url + version automatically). A "Retirer la section" button disables the auto-update by deleting the launcher key from versions.json. Lists the .exe files present in builds/launcher/ for visibility. Workflow now: edit the version in the form → SFTP-upload PSLauncher-X.Y.Z.exe to builds/launcher/ → click "🔁 Sync" once → manifest is hashed and signed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
452 lines
22 KiB
PHP
452 lines
22 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
require __DIR__ . '/lib/Auth.php';
|
|
require __DIR__ . '/lib/Layout.php';
|
|
|
|
use PSLauncher\Admin\Auth;
|
|
use PSLauncher\Admin\Layout;
|
|
|
|
Auth::requireLogin();
|
|
$config = require __DIR__ . '/../api/config.php';
|
|
|
|
$root = dirname(__DIR__);
|
|
$manifestPath = "$root/manifest/versions.json";
|
|
$buildsDir = "$root/builds";
|
|
$notesDir = "$root/releasenotes";
|
|
|
|
$message = null; $messageType = 'success';
|
|
|
|
function loadManifest(string $path): array
|
|
{
|
|
if (!is_file($path)) return ['schemaVersion' => 1, 'product' => 'PROSERVE_UE', 'latest' => null, 'versions' => []];
|
|
return json_decode(file_get_contents($path), true) ?? [];
|
|
}
|
|
|
|
function saveManifest(string $path, array $manifest): void
|
|
{
|
|
// Re-tri par SemVer descendant
|
|
usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version']));
|
|
file_put_contents(
|
|
$path,
|
|
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
|
|
);
|
|
}
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
Auth::checkCsrf();
|
|
$action = $_POST['action'] ?? '';
|
|
$manifest = loadManifest($manifestPath);
|
|
|
|
try {
|
|
if ($action === 'add') {
|
|
$version = trim($_POST['version'] ?? '');
|
|
$minLicDate = trim($_POST['min_license_date'] ?? '');
|
|
$releasedAt = trim($_POST['released_at'] ?? '');
|
|
$notes = $_POST['notes'] ?? '';
|
|
$available = isset($_POST['available']);
|
|
|
|
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
|
|
throw new Exception('Numéro de version invalide (format X.Y.Z attendu).');
|
|
}
|
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
|
throw new Exception('Date min_license_date invalide.');
|
|
}
|
|
foreach ($manifest['versions'] as $v) {
|
|
if ($v['version'] === $version) throw new Exception("v{$version} existe déjà dans le manifest.");
|
|
}
|
|
|
|
$releasedAtIso = $releasedAt !== ''
|
|
? (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z')
|
|
: (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
|
|
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
|
$manifest['versions'][] = [
|
|
'version' => $version,
|
|
'releasedAt' => $releasedAtIso,
|
|
'executable' => 'PROSERVE_UE_5_5.exe',
|
|
'installFolderTemplate' => 'Proserve v{version}',
|
|
'download' => [
|
|
'url' => "{$base}/builds/proserve-{$version}.zip",
|
|
'sizeBytes' => 0,
|
|
'sha256' => 'REPLACE_AFTER_BUILD',
|
|
],
|
|
'releaseNotesUrl' => "{$base}/api/releasenotes/{$version}",
|
|
'minLicenseDate' => $minLicDate,
|
|
'availableForDownload' => $available,
|
|
];
|
|
|
|
saveManifest($manifestPath, $manifest);
|
|
|
|
if ($notes !== '') {
|
|
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
|
|
file_put_contents("$notesDir/{$version}.md", $notes);
|
|
}
|
|
$message = "v{$version} ajoutée. Upload le ZIP en SFTP dans builds/, puis clique « Sync (sign-manifest) ».";
|
|
}
|
|
elseif ($action === 'edit_notes') {
|
|
$version = $_POST['version'] ?? '';
|
|
$notes = $_POST['notes'] ?? '';
|
|
if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) throw new Exception('Version invalide.');
|
|
if (!is_dir($notesDir)) mkdir($notesDir, 0755, true);
|
|
file_put_contents("$notesDir/{$version}.md", $notes);
|
|
$message = "Release notes de v{$version} mises à jour.";
|
|
}
|
|
elseif ($action === 'edit_meta') {
|
|
$version = $_POST['version'] ?? '';
|
|
$minLicDate = trim($_POST['min_license_date'] ?? '');
|
|
$releasedAt = trim($_POST['released_at'] ?? '');
|
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $minLicDate)) {
|
|
throw new Exception('min_license_date invalide.');
|
|
}
|
|
foreach ($manifest['versions'] as &$v) {
|
|
if ($v['version'] === $version) {
|
|
$v['minLicenseDate'] = $minLicDate;
|
|
if ($releasedAt !== '') {
|
|
$v['releasedAt'] = (new DateTime($releasedAt, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
unset($v);
|
|
saveManifest($manifestPath, $manifest);
|
|
$message = "Méta de v{$version} mises à jour.";
|
|
}
|
|
elseif ($action === 'toggle_available') {
|
|
$version = $_POST['version'] ?? '';
|
|
foreach ($manifest['versions'] as &$v) {
|
|
if ($v['version'] === $version) {
|
|
$v['availableForDownload'] = !($v['availableForDownload'] ?? true);
|
|
break;
|
|
}
|
|
}
|
|
unset($v);
|
|
saveManifest($manifestPath, $manifest);
|
|
$message = "Disponibilité de v{$version} mise à jour.";
|
|
}
|
|
elseif ($action === 'delete') {
|
|
$version = $_POST['version'] ?? '';
|
|
$manifest['versions'] = array_values(array_filter(
|
|
$manifest['versions'],
|
|
fn($v) => $v['version'] !== $version
|
|
));
|
|
saveManifest($manifestPath, $manifest);
|
|
$message = "v{$version} retirée du manifest. Le ZIP reste dans builds/ (à supprimer en SFTP si voulu).";
|
|
}
|
|
elseif ($action === 'set_launcher') {
|
|
$lver = trim($_POST['launcher_version'] ?? '');
|
|
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
|
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
|
|
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
|
|
}
|
|
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) {
|
|
throw new Exception('Numéro de version "minRequired" invalide.');
|
|
}
|
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
|
$manifest['launcher'] = [
|
|
'version' => $lver,
|
|
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
|
|
'download' => [
|
|
'url' => "{$base}/builds/launcher/PSLauncher-{$lver}.exe",
|
|
'sizeBytes' => 0,
|
|
'sha256' => 'REPLACE_AFTER_BUILD',
|
|
],
|
|
'releaseNotesUrl' => null,
|
|
];
|
|
saveManifest($manifestPath, $manifest);
|
|
$message = "Section launcher mise à jour (v{$lver}). Upload PSLauncher-{$lver}.exe dans builds/launcher/ puis clique « Sync ».";
|
|
}
|
|
elseif ($action === 'remove_launcher') {
|
|
unset($manifest['launcher']);
|
|
saveManifest($manifestPath, $manifest);
|
|
$message = "Section launcher retirée du manifest (auto-update désactivé).";
|
|
}
|
|
elseif ($action === 'sync') {
|
|
// Appel direct à la classe — pas d'exec(), fonctionne même quand la fonction
|
|
// est désactivée par le mutualisé OVH.
|
|
require_once "$root/tools/SignManifest.php";
|
|
$signer = new \PSLauncher\Tools\SignManifest($root);
|
|
$result = $signer->run();
|
|
$message = "Sortie du script :\n" . implode("\n", $result['log']);
|
|
if (!$result['ok']) $messageType = 'error';
|
|
}
|
|
} catch (Exception $e) {
|
|
$message = $e->getMessage();
|
|
$messageType = 'error';
|
|
}
|
|
}
|
|
|
|
$manifest = loadManifest($manifestPath);
|
|
$zips = is_dir($buildsDir) ? array_map('basename', glob("$buildsDir/*.zip") ?: []) : [];
|
|
$zipSizes = [];
|
|
foreach ($zips as $z) $zipSizes[$z] = filesize("$buildsDir/$z");
|
|
|
|
// Pour l'édition : pré-charge les release notes existantes
|
|
$existingNotes = [];
|
|
foreach ($manifest['versions'] ?? [] as $v) {
|
|
$f = "$notesDir/{$v['version']}.md";
|
|
$existingNotes[$v['version']] = is_file($f) ? file_get_contents($f) : '';
|
|
}
|
|
|
|
Layout::header('Versions', 'versions');
|
|
?>
|
|
<h1>Versions</h1>
|
|
|
|
<?php Layout::flash($message, $messageType); ?>
|
|
|
|
<div class="card">
|
|
<h2>Workflow d'une nouvelle release</h2>
|
|
<ol class="muted">
|
|
<li>Ajoute l'entrée du manifest avec le formulaire ci-dessous (version, date min de license, release notes).</li>
|
|
<li>Upload le ZIP correspondant via SFTP dans <code>www/PS_Launcher/builds/</code> en respectant le nom <code>proserve-{version}.zip</code>.</li>
|
|
<li>Clique <strong>🔁 Sync (sign-manifest)</strong> pour calculer le SHA-256, mettre à jour <code>sizeBytes</code>, bumper <code>latest</code>, et signer le manifest avec Ed25519.</li>
|
|
<li>Les clients PS_Launcher détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Ajouter une version</h2>
|
|
<form method="post">
|
|
<?= Layout::csrfField() ?>
|
|
<input type="hidden" name="action" value="add">
|
|
<div class="row">
|
|
<div class="col field">
|
|
<label>Version (X.Y.Z)</label>
|
|
<input type="text" name="version" placeholder="1.4.8" required pattern="\d+\.\d+\.\d+">
|
|
</div>
|
|
<div class="col field">
|
|
<label>Date de release</label>
|
|
<input type="datetime-local" name="released_at">
|
|
</div>
|
|
<div class="col field">
|
|
<label>min_license_date (license requise)</label>
|
|
<input type="date" name="min_license_date" required>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label>Release notes (Markdown)</label>
|
|
<textarea name="notes" rows="8" placeholder="# Proserve v1.4.8 ## Nouveautés - ..." style="font-family: 'Cascadia Code', Consolas, monospace;"></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label><input type="checkbox" name="available" checked> Disponible au téléchargement</label>
|
|
</div>
|
|
<button class="btn btn-success" type="submit">Ajouter au manifest</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Card LAUNCHER (auto-update du PSLauncher.exe lui-même) -->
|
|
<div class="card">
|
|
<h2>Auto-update du launcher</h2>
|
|
<?php
|
|
$launcher = $manifest['launcher'] ?? null;
|
|
$launcherFiles = is_dir("$buildsDir/launcher") ? array_map('basename', glob("$buildsDir/launcher/*.exe") ?: []) : [];
|
|
$launcherZipPresent = false;
|
|
$launcherHashed = false;
|
|
if ($launcher) {
|
|
$expectedFile = basename(parse_url($launcher['download']['url'] ?? '', PHP_URL_PATH) ?? '');
|
|
$launcherZipPresent = in_array($expectedFile, $launcherFiles, true);
|
|
$launcherHashed = !empty($launcher['download']['sha256']) && !str_starts_with($launcher['download']['sha256'], 'REPLACE');
|
|
}
|
|
?>
|
|
<?php if ($launcher): ?>
|
|
<p>
|
|
Version annoncée : <strong>v<?= htmlspecialchars($launcher['version']) ?></strong>
|
|
• minRequired : <code><?= htmlspecialchars($launcher['minRequired'] ?? '—') ?></code>
|
|
• Exe : <?= $launcherZipPresent
|
|
? "<span class='badge badge-success'>présent</span>"
|
|
: "<span class='badge badge-warning'>absent</span>" ?>
|
|
• Hash : <?= $launcherHashed
|
|
? "<span class='badge badge-success'>OK</span>"
|
|
: "<span class='badge badge-warning'>à calculer</span>" ?>
|
|
</p>
|
|
<p class="muted">URL attendue de l'exe : <code><?= htmlspecialchars($launcher['download']['url']) ?></code></p>
|
|
<?php else: ?>
|
|
<p class="muted">Section launcher absente du manifest — l'auto-update est désactivé. Configure-la ci-dessous pour l'activer.</p>
|
|
<?php endif; ?>
|
|
|
|
<h3 style="margin-top: 18px; font-size: 13px; color: var(--text-secondary); text-transform: uppercase;">Définir / mettre à jour</h3>
|
|
<form method="post">
|
|
<?= Layout::csrfField() ?>
|
|
<input type="hidden" name="action" value="set_launcher">
|
|
<div class="row">
|
|
<div class="col field">
|
|
<label>Version launcher (X.Y.Z)</label>
|
|
<input type="text" name="launcher_version" placeholder="0.6.0"
|
|
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
|
|
</div>
|
|
<div class="col field">
|
|
<label>Version minimale requise (X.Y.Z)</label>
|
|
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
|
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
|
|
</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<button class="btn btn-success" type="submit">Définir</button>
|
|
<?php if ($launcher): ?>
|
|
<form method="post" style="display:inline" onsubmit="return confirm('Retirer la section launcher du manifest ? L\'auto-update sera désactivé.')">
|
|
<?= Layout::csrfField() ?>
|
|
<input type="hidden" name="action" value="remove_launcher">
|
|
<button class="btn btn-danger" type="submit">Retirer la section</button>
|
|
</form>
|
|
<?php endif; ?>
|
|
</div>
|
|
</form>
|
|
|
|
<h3 style="margin-top: 18px; font-size: 13px; color: var(--text-secondary); text-transform: uppercase;">Workflow</h3>
|
|
<ol class="muted">
|
|
<li>Définis la version ci-dessus (ex : <code>0.6.0</code>).</li>
|
|
<li>Upload <code>PSLauncher-X.Y.Z.exe</code> via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
|
|
<li>Clique le bouton <strong>🔁 Sync (sign-manifest)</strong> du tableau du dessous — il hashera le launcher en plus des versions Proserve.</li>
|
|
</ol>
|
|
|
|
<?php if (!empty($launcherFiles)): ?>
|
|
<h3 style="margin-top: 18px; font-size: 13px; color: var(--text-secondary); text-transform: uppercase;">Exes présents dans builds/launcher/</h3>
|
|
<ul class="muted">
|
|
<?php foreach ($launcherFiles as $f): ?>
|
|
<li><code><?= htmlspecialchars($f) ?></code> — <?= Layout::formatBytes(filesize("$buildsDir/launcher/$f")) ?></li>
|
|
<?php endforeach; ?>
|
|
</ul>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="toolbar">
|
|
<h2 style="margin: 0; flex: 1;">Manifest actuel</h2>
|
|
<span class="muted">
|
|
Signature :
|
|
<?= empty($manifest['signature'])
|
|
? "<span class='badge badge-warning'>NON SIGNÉ</span>"
|
|
: "<span class='badge badge-success'>SIGNÉ Ed25519</span>" ?>
|
|
<?php if (!empty($manifest['latest'])): ?>
|
|
• latest : <code>v<?= htmlspecialchars($manifest['latest']) ?></code>
|
|
<?php endif; ?>
|
|
</span>
|
|
<form method="post" style="display:inline">
|
|
<?= Layout::csrfField() ?>
|
|
<input type="hidden" name="action" value="sync">
|
|
<button class="btn btn-primary" type="submit">🔁 Sync (sign-manifest)</button>
|
|
</form>
|
|
</div>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Version</th>
|
|
<th>Release</th>
|
|
<th>min_license</th>
|
|
<th>ZIP</th>
|
|
<th>Hash</th>
|
|
<th>Visible</th>
|
|
<th style="text-align:right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($manifest['versions'] ?? [] as $v):
|
|
$zipBase = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
|
|
$zipExists = in_array($zipBase, $zips, true);
|
|
$hashed = !empty($v['download']['sha256']) && !str_starts_with($v['download']['sha256'], 'REPLACE');
|
|
?>
|
|
<tr>
|
|
<td><strong>v<?= htmlspecialchars($v['version']) ?></strong></td>
|
|
<td class="muted"><?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 10)) ?></td>
|
|
<td class="muted"><?= htmlspecialchars($v['minLicenseDate'] ?? '—') ?></td>
|
|
<td>
|
|
<?php if ($zipExists): ?>
|
|
<span class="badge badge-success">présent</span>
|
|
<span class="muted"><?= Layout::formatBytes($zipSizes[$zipBase] ?? 0) ?></span>
|
|
<?php else: ?>
|
|
<span class="badge badge-warning">absent</span>
|
|
<div class="muted" style="font-size: 11px;">attendu : <code><?= htmlspecialchars($zipBase) ?></code></div>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td>
|
|
<?php if ($hashed): ?>
|
|
<code title="<?= htmlspecialchars($v['download']['sha256']) ?>"><?= substr($v['download']['sha256'], 0, 12) ?>…</code>
|
|
<?php else: ?>
|
|
<span class="badge badge-warning">à calculer</span>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td>
|
|
<form method="post" style="display:inline">
|
|
<?= Layout::csrfField() ?>
|
|
<input type="hidden" name="action" value="toggle_available">
|
|
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
|
<button class="btn btn-secondary" type="submit">
|
|
<?= ($v['availableForDownload'] ?? true) ? '✓' : '✗' ?>
|
|
</button>
|
|
</form>
|
|
</td>
|
|
<td style="text-align: right; white-space: nowrap;">
|
|
<details style="display: inline-block; margin: 0 4px;">
|
|
<summary class="btn btn-secondary">Méta</summary>
|
|
<form method="post" style="margin-top: 8px;">
|
|
<?= Layout::csrfField() ?>
|
|
<input type="hidden" name="action" value="edit_meta">
|
|
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
|
<div class="field">
|
|
<label>min_license_date</label>
|
|
<input type="date" name="min_license_date" value="<?= htmlspecialchars($v['minLicenseDate'] ?? '') ?>" required>
|
|
</div>
|
|
<div class="field">
|
|
<label>Date de release (UTC)</label>
|
|
<input type="datetime-local" name="released_at" value="<?= htmlspecialchars(substr($v['releasedAt'] ?? '', 0, 16)) ?>">
|
|
</div>
|
|
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
|
</form>
|
|
</details>
|
|
<details style="display: inline-block; margin: 0 4px;">
|
|
<summary class="btn btn-secondary">Notes</summary>
|
|
<form method="post" style="margin-top: 8px;">
|
|
<?= Layout::csrfField() ?>
|
|
<input type="hidden" name="action" value="edit_notes">
|
|
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
|
<textarea name="notes" rows="10" style="font-family: 'Cascadia Code', Consolas, monospace; min-width: 400px;"><?= htmlspecialchars($existingNotes[$v['version']] ?? '') ?></textarea>
|
|
<br><br>
|
|
<button class="btn btn-primary" type="submit">Enregistrer les notes</button>
|
|
</form>
|
|
</details>
|
|
<form method="post" style="display:inline" onsubmit="return confirm('Retirer v<?= htmlspecialchars($v['version']) ?> du manifest ?\n(Le ZIP reste dans builds/, supprime-le en SFTP si voulu.)')">
|
|
<?= Layout::csrfField() ?>
|
|
<input type="hidden" name="action" value="delete">
|
|
<input type="hidden" name="version" value="<?= htmlspecialchars($v['version']) ?>">
|
|
<button class="btn btn-danger" type="submit">Retirer</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
<?php if (empty($manifest['versions'])): ?>
|
|
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune version dans le manifest.</td></tr>
|
|
<?php endif; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>ZIPs présents dans builds/</h2>
|
|
<?php if (empty($zips)): ?>
|
|
<p class="muted">Aucun ZIP. Upload tes fichiers via SFTP dans <code>www/PS_Launcher/builds/</code>, puis clique « Sync » ci-dessus.</p>
|
|
<?php else: ?>
|
|
<table>
|
|
<thead><tr><th>Fichier</th><th>Taille</th><th>Référencé ?</th></tr></thead>
|
|
<tbody>
|
|
<?php
|
|
$referencedNames = [];
|
|
foreach ($manifest['versions'] ?? [] as $v) {
|
|
$referencedNames[] = basename(parse_url($v['download']['url'], PHP_URL_PATH) ?? '');
|
|
}
|
|
foreach ($zips as $z):
|
|
$referenced = in_array($z, $referencedNames, true);
|
|
?>
|
|
<tr>
|
|
<td><code><?= htmlspecialchars($z) ?></code></td>
|
|
<td class="muted"><?= Layout::formatBytes($zipSizes[$z]) ?></td>
|
|
<td><?= $referenced
|
|
? "<span class='badge badge-success'>oui</span>"
|
|
: "<span class='badge badge-warning'>orphelin</span>" ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php Layout::footer();
|