Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions library/Tiger/Model/UpdateHistory.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,63 @@ public function record(array $data)
* @param int $limit
* @return array<int,array>
*/
/**
* DataTables data for the Updates history grid: search, sort and paging over the
* durable update log. Owns the query; the service handles presentation + ACL
* (AGENTS.md — a service never builds a predicate). Mirrors
* `Tiger_Model_Page::datatable()`, which is the reference implementation.
*
* @param array{search?:string,orderCol?:int,orderDir?:string,offset?:int,limit?:int} $opts
* @return array{total:int,filtered:int,rows:array}
*/
public function datatable(array $opts)
{
$db = $this->getAdapter();
$search = (string) ($opts['search'] ?? '');
$limit = max(1, (int) ($opts['limit'] ?? 25));
$offset = max(0, (int) ($opts['offset'] ?? 0));

// Sortable columns (grid index -> SQL). The log column is not sortable.
$orderCols = [0 => 'item_name', 1 => 'item_type', 2 => 'created_at', 3 => 'to_version', 4 => 'outcome'];
$col = (int) ($opts['orderCol'] ?? -1);
$dir = (strtoupper((string) ($opts['orderDir'] ?? '')) === 'ASC') ? 'ASC' : 'DESC';
$orderSql = isset($orderCols[$col]) ? ($orderCols[$col] . ' ' . $dir) : 'created_at DESC';

$scope = function ($sel) { $sel->where('deleted = 0'); };
$searchFn = function ($sel) use ($db, $search) {
if ($search === '') { return; }
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search) . '%';
$parts = [];
foreach (['item_name', 'item_slug', 'item_type', 'from_version', 'to_version', 'outcome'] as $c) {
$parts[] = $db->quoteInto("$c LIKE ?", $like);
}
$sel->where('(' . implode(' OR ', $parts) . ')');
};

$totalSel = $db->select()->from($this->_name, ['c' => new Zend_Db_Expr('COUNT(*)')]);
$scope($totalSel);
$total = (int) $db->fetchOne($totalSel);

$filteredSel = $db->select()->from($this->_name, ['c' => new Zend_Db_Expr('COUNT(*)')]);
$scope($filteredSel); $searchFn($filteredSel);
$filtered = (int) $db->fetchOne($filteredSel);

$pageSel = $db->select()
->from($this->_name, ['update_id', 'item_type', 'item_slug', 'item_name',
'from_version', 'to_version', 'outcome', 'log', 'created_at'])
->order(new Zend_Db_Expr($orderSql))
->limit($limit, $offset);
$scope($pageSel); $searchFn($pageSel);

$rows = $db->fetchAll($pageSel);
foreach ($rows as &$r) {
$r['log'] = ($r['log'] !== null && $r['log'] !== '') ? (json_decode((string) $r['log'], true) ?: []) : [];
}
unset($r);

return ['total' => $total, 'filtered' => $filtered, 'rows' => $rows];
}

public function recent($limit = 20)
{
$rows = $this->activeSelect()->order('created_at DESC')->limit(max(1, (int) $limit))->query()->fetchAll();
Expand Down
58 changes: 58 additions & 0 deletions modules/system/assets/js/system.updates.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,62 @@
.catch(function () { TigerDOM.notify(fb, t('networkErrorShort'), { type: 'error' }); });
});
}
// ----- history grid ------------------------------------------------------
// Rows come from /api (System_Service_Updates::datatable) per WEBSERVICES.md §5 —
// never server-rendered. The per-run step log hangs off a DataTables CHILD ROW,
// which is how DT expresses "extra detail under this row"; the log travels with
// the row payload, so expanding costs no round trip.
if (typeof tigerDataTable === 'function' && document.getElementById('updates-history-table')) {
var esc = tigerDataTable.esc;
var OUTCOME_BADGE = {
success: 'text-bg-success', failed: 'text-bg-danger',
rolled_back: 'text-bg-warning', advisory: 'text-bg-secondary'
};
var KIND_ICON = { core: 'fa-cube', skill: 'fa-graduation-cap' };
var KIND_LABEL = { core: 'badgePlatform', skill: 'badgeSkill' };

function logHtml(log) {
var out = (log || []).map(function (s) {
return esc((s && s.ok ? '· ' : '✗ ') + '[' + (s && s.step) + '] ' + (s && s.detail));
}).join('\n');
return '<pre class="small mb-2 bg-body-tertiary rounded p-2 updates-prewrap">' + out + '</pre>';
}

var hTable = tigerDataTable('#updates-history-table', {
service: { module: 'system', service: 'updates', action: 'datatable' },
order: [[2, 'desc']],
columns: [
{ data: 'item_name', className: 'ps-3', render: function (v, type, row) {
if (type !== 'display') { return v; }
var ic = KIND_ICON[row.item_type] || 'fa-puzzle-piece';
return '<i class="fa-solid fa-fw me-1 text-body-secondary ' + ic + '"></i>' + esc(v);
} },
{ data: 'item_type', render: function (v, type) {
if (type !== 'display') { return v; }
return '<span class="badge text-bg-light border">' + esc(t(KIND_LABEL[v] || 'badgeModule')) + '</span>';
} },
{ data: 'created', className: 'small text-body-secondary text-nowrap' },
{ data: 'versions', className: 'small font-monospace text-body-secondary' },
{ data: 'outcome', render: function (v, type) {
if (type !== 'display') { return v; }
return '<span class="badge ' + (OUTCOME_BADGE[v] || 'text-bg-light') + '">' + esc(v) + '</span>';
} },
{ data: null, orderable: false, searchable: false, className: 'pe-3 text-end', render: function (v, type, row) {
if (type !== 'display' || !row.log || !row.log.length) { return ''; }
var ll = esc(t('logLink'));
return '<button class="btn btn-sm btn-link p-0 updates-log-toggle" type="button" '
+ 'aria-label="' + ll + '" title="' + ll + '"><i class="fa-solid fa-file-lines"></i></button>';
} }
]
});

document.getElementById('updates-history-table').addEventListener('click', function (e) {
var btn = e.target.closest('.updates-log-toggle');
if (!btn || !hTable) { return; }
var tr = btn.closest('tr');
var row = hTable.row(tr);
if (row.child.isShown()) { row.child.hide(); return; }
row.child(logHtml(row.data().log)).show();
});
}
})();
10 changes: 4 additions & 6 deletions modules/system/controllers/UpdatesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,9 @@ public function indexAction()
$this->view->pending = $pending;
$this->view->currentVersion = Tiger_Version::VERSION; // the installed TigerCore version, always shown

// Durable history (empty until the migration runs — never let it break the screen).
try {
$this->view->history = (new Tiger_Model_UpdateHistory())->recent(15);
} catch (Throwable $e) {
$this->view->history = [];
}
// The history grid fetches its own rows from /api (System_Service_Updates::datatable)
// per the client/server contract — the controller renders the shell only. WITHOUT this
// flag the DataTables assets never load and the grid renders empty.
$this->view->useDataTables = true;
}
}
56 changes: 56 additions & 0 deletions modules/system/services/Updates.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,65 @@ public static function runScheduledAutoUpdates(): array
}
}

/**
* DataTables rows for the Updates history grid (WEBSERVICES.md §5): the durable
* "what ran / what broke" log, searchable and paged, so the whole history is
* reachable rather than the newest handful.
*
* Rows are STRUCTURED data only — never HTML. The `log` array rides along so the
* client can draw the expandable detail row without a second round trip; it is
* already small (one short entry per update step).
*
* @param array $params the DataTables request (draw/start/length/search/order)
* @return void
*/
public function datatable(array $params): void
{
if (!$this->_isAllowed('datatable')) { $this->_error('core.api.error.not_allowed'); return; }

$dt = $this->_dtParams($params);

try {
// The query lives in the model; the service formats + ACL-gates.
$data = (new Tiger_Model_UpdateHistory())->datatable([
'search' => $dt['search'],
'orderCol' => isset($dt['order'][0]) ? $dt['order'][0]['column'] : -1,
'orderDir' => isset($dt['order'][0]) ? $dt['order'][0]['dir'] : '',
'offset' => $dt['start'],
'limit' => $dt['length'],
]);
} catch (Throwable $e) {
// The table is absent until the migration runs — an empty grid, never a broken screen.
$this->_dtResponse($dt['draw'], 0, 0, []);
return;
}

$rows = [];
foreach ($data['rows'] as $r) {
$rows[] = [
'update_id' => (string) $r['update_id'],
'item_type' => (string) $r['item_type'],
'item_name' => (string) ($r['item_name'] !== '' ? $r['item_name'] : $r['item_slug']),
// A failed install can have neither version; a bare "→" reads as a rendering bug.
'versions' => ($r['from_version'] || $r['to_version'])
? trim((string) $r['from_version'] . ' → ' . (string) $r['to_version'])
: '—',
'outcome' => (string) $r['outcome'],
'created' => (string) $r['created_at'],
'log' => array_values((array) $r['log']),
];
}

$this->_dtResponse($dt['draw'], (int) $data['total'], (int) $data['filtered'], $rows);
}

/**
* The recent update-run history (durable "what ran / what broke").
*
* Kept alongside `datatable()`: this is the plain read an API/MCP caller wants
* ("what has run lately?") without DataTables semantics, and `history` is a
* READ_VERB so a read-only token can reach it. The admin grid uses `datatable()`.
*
* @param array $params {limit?}
* @return void
*/
Expand Down
55 changes: 13 additions & 42 deletions modules/system/views/scripts/updates/index.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -108,58 +108,24 @@ $pending = $this->pending ?? [];
</div>
</div>

<?php if (!empty($this->history)): ?>
<div class="card border-0 shadow-sm mt-4">
<div class="card-header fw-semibold"><i class="fa-solid fa-clock-rotate-left me-2"></i><?= $this->t('system.updates.history') ?></div>
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<?php /* Rows come from /api (System_Service_Updates::datatable) — never server-rendered. */ ?>
<table id="updates-history-table" class="table table-sm align-middle mb-0" style="width:100%;">
<thead>
<tr class="text-body-secondary small">
<th class="ps-3"><?= $this->t('system.updates.col_item') ?></th><th><?= $this->t('system.updates.col_kind') ?></th><th><?= $this->t('system.updates.col_date') ?></th><th><?= $this->t('system.updates.col_version') ?></th><th><?= $this->t('system.updates.col_result') ?></th><th class="pe-3 text-end"><?= $this->t('system.updates.col_log') ?></th>
<th class="ps-3"><?= $this->t('system.updates.col_item') ?></th>
<th><?= $this->t('system.updates.col_kind') ?></th>
<th><?= $this->t('system.updates.col_date') ?></th>
<th><?= $this->t('system.updates.col_version') ?></th>
<th><?= $this->t('system.updates.col_result') ?></th>
<th class="pe-3 text-end"><?= $this->t('system.updates.col_log') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->history as $i => $h):
$badge = ['success' => 'text-bg-success', 'failed' => 'text-bg-danger',
'rolled_back' => 'text-bg-warning', 'advisory' => 'text-bg-secondary'][$h['outcome']] ?? 'text-bg-light';
?>
<tr>
<?php
$hicon = $h['item_type'] === 'core' ? 'fa-cube' : ($h['item_type'] === 'skill' ? 'fa-graduation-cap' : 'fa-puzzle-piece');
$hkind = ['core' => 'system.updates.badge_platform', 'skill' => 'system.updates.badge_skill'][$h['item_type']] ?? 'system.updates.badge_module';
?>
<td class="ps-3">
<i class="fa-solid fa-fw me-1 text-body-secondary <?= $hicon ?>"></i>
<?= $this->escape($h['item_name'] ?: $h['item_slug']) ?>
</td>
<td><span class="badge text-bg-light border"><?= $this->t($hkind) ?></span></td>
<td class="small text-body-secondary text-nowrap"><?= $this->escape($h['created_at']) ?></td>
<td class="small font-monospace text-body-secondary"><?= $this->escape($h['from_version']) ?> → <?= $this->escape($h['to_version']) ?></td>
<td><span class="badge <?= $badge ?>"><?= $this->escape($h['outcome']) ?></span></td>
<td class="pe-3 text-end">
<?php if (!empty($h['log'])): $ll = $this->escape($this->t('system.updates.log_link')); ?>
<button class="btn btn-sm btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#uh-<?= (int) $i ?>"
aria-label="<?= $ll ?>" title="<?= $ll ?>"><i class="fa-solid fa-file-lines"></i></button>
<?php endif; ?>
</td>
</tr>
<?php if (!empty($h['log'])): ?>
<tr class="collapse" id="uh-<?= (int) $i ?>">
<td colspan="6" class="ps-3 pe-3">
<pre class="small mb-2 bg-body-tertiary rounded p-2 updates-prewrap"><?php
foreach ($h['log'] as $s) {
echo (!empty($s['ok']) ? '· ' : '✗ ') . '[' . $this->escape($s['step']) . '] ' . $this->escape($s['detail']) . "\n";
}
?></pre>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>

<?php
$this->i18n([
Expand All @@ -172,6 +138,11 @@ $this->i18n([
'stateFailed' => 'system.updates.state_failed',
'stateManual' => 'system.updates.state_manual',
'stateCurrent' => 'system.updates.state_current',
// the history grid draws its own cells client-side, so its labels ship to JS
'badgePlatform' => 'system.updates.badge_platform',
'badgeSkill' => 'system.updates.badge_skill',
'badgeModule' => 'system.updates.badge_module',
'logLink' => 'system.updates.log_link',
]);
?>
<script src="<?= $this->escape($this->asset('/_modules/system/js/system.updates.js')) ?>" defer></script>
6 changes: 5 additions & 1 deletion tests/Integration/System/SystemControllerDispatchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,11 @@ public function the_updates_screen_reports_detection_without_reaching_the_networ
$this->assertIsArray($this->controller()->view->updates, 'the checker result is passed to the view');
// Seeded low "latest" → nothing pending → the pending list is empty (and no notes were fetched).
$this->assertSame([], $this->controller()->view->pending);
$this->assertIsArray($this->controller()->view->history);
// History rows are no longer a view-var: the grid fetches them from /api
// (System_Service_Updates::datatable, TIGER-383). The controller's job here is to
// opt into the DataTables assets — without that flag the grid renders empty.
$this->assertTrue((bool) $this->controller()->view->useDataTables, 'the history grid needs the DataTables assets');
$this->assertNull($this->controller()->view->history ?? null, 'history is fetched, not server-rendered');
}

// ---- cache seeding helpers -------------------------------------------------------------------
Expand Down
69 changes: 69 additions & 0 deletions tests/Integration/System/UpdatesControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.

namespace Tiger\Tests\Integration\System;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use System_UpdatesController;
use Tiger\Tests\Support\ControllerTestCase;
use Zend_Session;

/**
* System_UpdatesController — the Updates screen shell.
*
* The controller is thin READ+RENDER: it offers the pending updates and renders the history GRID's
* shell. The history rows themselves come from /api (System_Service_Updates::datatable, covered by
* the service tests) — they are NOT server-rendered, which is the client/server contract this screen
* was converted to (TIGER-383).
*
* The load-bearing assertion is `useDataTables`. Without that flag the DataTables assets never load,
* the grid renders as an empty table, and the page's own JS dies after the failed init — silently, with
* a 200 and no console error a casual look would catch. It is the exact failure this screen would hit
* first, so it gets its own test rather than being trusted to a code review.
*/
#[CoversClass(System_UpdatesController::class)]
final class UpdatesControllerTest extends ControllerTestCase
{
private bool $priorUnitTestMode;

protected function setUp(): void
{
parent::setUp();
$this->priorUnitTestMode = Zend_Session::$_unitTestEnabled;
Zend_Session::$_unitTestEnabled = true;
$_SESSION = [];
}

protected function tearDown(): void
{
$_SESSION = [];
Zend_Session::$_unitTestEnabled = $this->priorUnitTestMode;
parent::tearDown();
}

#[Test]
public function index_renders_the_updates_shell_and_opts_into_the_dataTables_assets(): void
{
$this->loginAs('superadmin');
$res = $this->dispatchAction(System_UpdatesController::class, 'index', [], 'GET');
$this->assertSame(200, $res->getHttpResponseCode());

$view = $this->controller()->view;
$this->assertSame('Updates — Tiger Admin', $view->title);
$this->assertTrue((bool) $view->useDataTables, 'the history grid needs the DataTables assets, or it renders empty');
}

#[Test]
public function the_controller_no_longer_server_renders_the_history_rows(): void
{
$this->loginAs('superadmin');
$this->dispatchAction(System_UpdatesController::class, 'index', [], 'GET');

// The guard for the regression this screen just came back from: if a `history` view-var
// reappears, someone has gone back to rendering rows on the server (WEBSERVICES.md §5).
$this->assertNull($this->controller()->view->history ?? null,
'history rows come from /api, not a controller view-var');
}
}
Loading
Loading