diff --git a/library/Tiger/Model/UpdateHistory.php b/library/Tiger/Model/UpdateHistory.php index d7882267..8c7da608 100644 --- a/library/Tiger/Model/UpdateHistory.php +++ b/library/Tiger/Model/UpdateHistory.php @@ -46,6 +46,63 @@ public function record(array $data) * @param int $limit * @return 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(); diff --git a/modules/system/assets/js/system.updates.js b/modules/system/assets/js/system.updates.js index b9138619..095732c5 100644 --- a/modules/system/assets/js/system.updates.js +++ b/modules/system/assets/js/system.updates.js @@ -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 '
' + out + '
'; + } + + 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 '' + esc(v); + } }, + { data: 'item_type', render: function (v, type) { + if (type !== 'display') { return v; } + return '' + esc(t(KIND_LABEL[v] || 'badgeModule')) + ''; + } }, + { 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 '' + esc(v) + ''; + } }, + { 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 ''; + } } + ] + }); + + 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(); + }); + } })(); diff --git a/modules/system/controllers/UpdatesController.php b/modules/system/controllers/UpdatesController.php index ae611b08..2a471f22 100644 --- a/modules/system/controllers/UpdatesController.php +++ b/modules/system/controllers/UpdatesController.php @@ -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; } } diff --git a/modules/system/services/Updates.php b/modules/system/services/Updates.php index c9498aa3..0b2412d1 100644 --- a/modules/system/services/Updates.php +++ b/modules/system/services/Updates.php @@ -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 */ diff --git a/modules/system/views/scripts/updates/index.phtml b/modules/system/views/scripts/updates/index.phtml index 932229d7..1eee38d7 100644 --- a/modules/system/views/scripts/updates/index.phtml +++ b/modules/system/views/scripts/updates/index.phtml @@ -108,58 +108,24 @@ $pending = $this->pending ?? []; -history)): ?>
t('system.updates.history') ?>
- + +
- + + + + + + - - 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'; - ?> - - 'system.updates.badge_platform', 'skill' => 'system.updates.badge_skill'][$h['item_type']] ?? 'system.updates.badge_module'; - ?> - - - - - - - - - - - - - -
t('system.updates.col_item') ?>t('system.updates.col_kind') ?>t('system.updates.col_date') ?>t('system.updates.col_version') ?>t('system.updates.col_result') ?>t('system.updates.col_log') ?>t('system.updates.col_item') ?>t('system.updates.col_kind') ?>t('system.updates.col_date') ?>t('system.updates.col_version') ?>t('system.updates.col_result') ?>t('system.updates.col_log') ?>
- - escape($h['item_name'] ?: $h['item_slug']) ?> - t($hkind) ?>escape($h['created_at']) ?>escape($h['from_version']) ?> → escape($h['to_version']) ?>escape($h['outcome']) ?> - escape($this->t('system.updates.log_link')); ?> - - -
-
escape($s['step']) . '] ' . $this->escape($s['detail']) . "\n";
-                                    }
-                                ?>
-
- i18n([ @@ -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', ]); ?> diff --git a/tests/Integration/System/SystemControllerDispatchTest.php b/tests/Integration/System/SystemControllerDispatchTest.php index fa1c358a..902d2769 100644 --- a/tests/Integration/System/SystemControllerDispatchTest.php +++ b/tests/Integration/System/SystemControllerDispatchTest.php @@ -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 ------------------------------------------------------------------- diff --git a/tests/Integration/System/UpdatesControllerTest.php b/tests/Integration/System/UpdatesControllerTest.php new file mode 100644 index 00000000..f5a3bdb0 --- /dev/null +++ b/tests/Integration/System/UpdatesControllerTest.php @@ -0,0 +1,69 @@ +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'); + } +} diff --git a/tests/Integration/System/UpdatesServiceTest.php b/tests/Integration/System/UpdatesServiceTest.php index f379523f..b02ff908 100644 --- a/tests/Integration/System/UpdatesServiceTest.php +++ b/tests/Integration/System/UpdatesServiceTest.php @@ -78,6 +78,62 @@ public function apply_with_no_selection_is_a_clean_error_not_a_crash(): void $this->assertStringNotContainsString('not_allowed', $this->messages($res), 'superadmin cleared the ACL gate first'); } + #[Test] + public function datatable_returns_the_dataTables_envelope_and_structured_rows_only(): void + { + $this->loginAs('superadmin'); + (new \Tiger_Model_UpdateHistory())->record([ + 'item_type' => 'module', 'item_slug' => 'dt-probe', 'item_name' => 'DT Probe', + 'from_version' => '1.0.0', 'to_version' => '1.1.0', 'outcome' => 'success', + 'log' => [['step' => 'download', 'detail' => 'ok', 'ok' => true]], + ]); + + $res = $this->dispatch(['action' => 'datatable', 'draw' => 3, 'start' => 0, 'length' => 10]); + + $this->assertSame(1, (int) $res->result, 'the grid read succeeds'); + $this->assertSame(3, (int) $res->data['draw'], 'draw is echoed back, as DataTables requires'); + $this->assertArrayHasKey('recordsTotal', $res->data); + $this->assertArrayHasKey('recordsFiltered', $res->data); + $this->assertGreaterThan(0, (int) $res->data['recordsTotal'], 'the row we just recorded is counted'); + + $row = $res->data['data'][0]; + // WEBSERVICES.md §5: rows are STRUCTURED data only — never HTML. + foreach (['item_name', 'item_type', 'versions', 'outcome', 'created', 'log'] as $k) { + $this->assertArrayHasKey($k, $row, "the row carries $k"); + } + $this->assertIsArray($row['log'], 'the step log rides along for the child row'); + $this->assertStringNotContainsString('<', json_encode($row), 'no markup is built on the server'); + } + + #[Test] + public function datatable_search_narrows_recordsFiltered_without_changing_the_total(): void + { + $this->loginAs('superadmin'); + $model = new \Tiger_Model_UpdateHistory(); + $tag = 'zz-' . bin2hex(random_bytes(4)); + $model->record(['item_type' => 'module', 'item_slug' => $tag, 'item_name' => $tag, + 'from_version' => '1.0.0', 'to_version' => '1.0.1', 'outcome' => 'success', 'log' => []]); + $model->record(['item_type' => 'module', 'item_slug' => 'other-one', 'item_name' => 'Other One', + 'from_version' => '1.0.0', 'to_version' => '1.0.1', 'outcome' => 'failed', 'log' => []]); + + $all = $this->dispatch(['action' => 'datatable', 'draw' => 1, 'start' => 0, 'length' => 10]); + $hit = $this->dispatch(['action' => 'datatable', 'draw' => 2, 'start' => 0, 'length' => 10, + 'search' => ['value' => $tag]]); + + $this->assertSame((int) $all->data['recordsTotal'], (int) $hit->data['recordsTotal'], + 'the unfiltered total does not move when the search box is used'); + $this->assertSame(1, (int) $hit->data['recordsFiltered'], 'the search narrows to the one match'); + $this->assertSame($tag, $hit->data['data'][0]['item_name']); + } + + #[Test] + public function datatable_is_denied_to_a_plain_admin(): void + { + $this->loginAs('admin'); + $res = $this->dispatch(['action' => 'datatable', 'draw' => 1]); + $this->assertSame(0, (int) $res->result, 'the history grid is superadmin+, like the rest of the updater'); + } + #[Test] public function history_returns_a_list_for_a_superadmin(): void {