diff --git a/packages/web/commons/schema.php b/packages/web/commons/schema.php
index 56193525d8..6989ef2929 100644
--- a/packages/web/commons/schema.php
+++ b/packages/web/commons/schema.php
@@ -5878,3 +5878,24 @@ function () {
. "(6,'Failed','Host reported that the task could not be completed.',"
. "6,'exclamation-triangle')",
];
+// 340
+$this->schema[] = [
+ // Retype the rows that landed untyped between step 338 and the model
+ // learning to type them.
+ //
+ // Step 338 gave `logType` a DEFAULT of 'state', which reads as though a
+ // writer that sets no type gets one. It does not: a column default
+ // applies only when the column is absent from the INSERT, and
+ // FOGController::save() writes every declared field -- so
+ // TaskingElement::taskLog(), which has recorded state changes since long
+ // before this column existed, has been writing '' ever since the field
+ // was declared. TaskLog::__construct() now supplies the type, and this
+ // repairs what the gap produced.
+ //
+ // Scoped to '' (and NULL, which the column does not allow today but a
+ // hand-edited schema might). Nothing else can be mistaken for it: the
+ // only other values are written deliberately by the FOS report endpoint.
+ "UPDATE `taskLog` "
+ . "SET `logType` = 'state' "
+ . "WHERE `logType` = '' OR `logType` IS NULL",
+];
diff --git a/packages/web/lib/fog/system.class.php b/packages/web/lib/fog/system.class.php
index dc73b92f7b..d0975f6356 100644
--- a/packages/web/lib/fog/system.class.php
+++ b/packages/web/lib/fog/system.class.php
@@ -94,8 +94,8 @@ public function __construct()
// 1.5.x carried count does, see SchemaReconciler's docstring -- is
// permanently "up to date" from the updater's point of view and will
// never run another indexed step, whatever this constant says.
- define('FOG_SCHEMA', 339);
- define('FOG_BCACHE_VER', 286);
+ define('FOG_SCHEMA', 340);
+ define('FOG_BCACHE_VER', 287);
define('FOG_CLIENT_VERSION', '0.13.0');
// GH-959: iPXE lives in FOGProject/fog-ipxe and its binaries arrive as
// a release asset. Pinned here rather than tracked as "latest" so a
diff --git a/packages/web/lib/fog/tasklog.class.php b/packages/web/lib/fog/tasklog.class.php
index a877a6ec6f..5257830d27 100644
--- a/packages/web/lib/fog/tasklog.class.php
+++ b/packages/web/lib/fog/tasklog.class.php
@@ -67,6 +67,23 @@ class TaskLog extends FOGController
/**
* Initializes the class to set the ip from the remote.
*
+ * Also types the row, because the column default cannot. Schema 338 gave
+ * `logType` a DEFAULT of 'state', and a default only applies when the
+ * column is left out of the INSERT -- which FOGController::save() never
+ * does: it writes every declared field, so an unset one arrives as ''.
+ * So TaskingElement::taskLog(), which has recorded task state changes
+ * since long before this column existed and sets no type, started writing
+ * untyped rows the moment the field was declared. Proven on a live
+ * install 2026-08-19: one row with logType '' against 52 pre-existing
+ * rows reading 'state', those 52 being rows the ALTER had backfilled.
+ *
+ * The consequence is silent: Task Management's log pane filters on
+ * `logType IN ('state')`, so every state row written after the upgrade
+ * would be missing from the one view built to show them.
+ *
+ * Guarded rather than assigned, so loading an existing row and saving it
+ * cannot retype it as a state change.
+ *
* @param mixed $data the data to initialize with.
*
* @return void
@@ -75,6 +92,9 @@ public function __construct($data = '')
{
parent::__construct($data);
$this->set('ip', self::$remoteaddr);
+ if ('' === (string) $this->get('type')) {
+ $this->set('type', self::TYPE_STATE);
+ }
}
/**
* Gets the task object.
diff --git a/packages/web/lib/pages/taskmanagement.page.php b/packages/web/lib/pages/taskmanagement.page.php
index e04563d8d4..74e555f5c7 100644
--- a/packages/web/lib/pages/taskmanagement.page.php
+++ b/packages/web/lib/pages/taskmanagement.page.php
@@ -536,6 +536,32 @@ private function _logsPane()
echo '';
echo '';
$this->render(12, 'task-logs-table');
+ // Clicking a row opens this. The message column is the one that gets
+ // truncated on a narrow viewport, and it is also the only column
+ // whose whole point is its text -- a FOS report carries the script it
+ // came from and the arguments it was passed. The modal is filled
+ // client side from the row the grid already has, so opening it costs
+ // no request.
+ //
+ // Dismiss only: nothing here is editable, so there is no commit
+ // button for it to sit to the left of, and it takes the outline
+ // secondary a modal dismiss always takes.
+ echo self::makeModal(
+ 'task-log-modal',
+ '
'
+ . _('Log entry')
+ . '
',
+ '
',
+ self::makeButton(
+ 'task-log-close',
+ _('Close'),
+ 'btn btn-outline-secondary float-start',
+ 'data-bs-dismiss="modal"'
+ ),
+ '',
+ 'default',
+ 'modal-lg'
+ );
}
/**
* Get the task log entries.
diff --git a/packages/web/management/js/fog/task/fog.task.list.js b/packages/web/management/js/fog/task/fog.task.list.js
index 22af9ed6c8..85348fe030 100644
--- a/packages/web/management/js/fog/task/fog.task.list.js
+++ b/packages/web/management/js/fog/task/fog.task.list.js
@@ -472,9 +472,13 @@
targets: 3
},
{
+ // Ranked above task type and state, because it is the column that
+ // says whether the machine stopped or carried on -- the first thing
+ // anyone opening this tab is looking for, and the one that must not
+ // be the one Responsive collapses away on a laptop.
+ responsivePriority: 2,
render: function(data) {
- // The type is what tells an operator whether the machine stopped
- // or carried on, so it is badged rather than left as bare text.
+ // Badged rather than left as bare text, for the same reason.
var cls = {error: 'bg-danger', warning: 'bg-warning text-dark'};
return ''
+ $.escapeHtml(data || '')
@@ -494,10 +498,65 @@
data: function(d) {
d.logtypes = $('input[name="log-type-filter"]:checked').val();
}
+ },
+ createdRow: function(row) {
+ // The whole row is the target, so say so: without a pointer there is
+ // nothing to suggest the message has more behind it.
+ $(row).css('cursor', 'pointer');
}
});
}
+ // ---------------------------------------------------------------
+ // LOG ENTRY DETAIL
+ //
+ // Filled from the row the grid already holds -- no request. The message is
+ // the reason this exists: it is the column that truncates on a narrow
+ // viewport, and a FOS report carries the script it came from and the
+ // arguments it was passed, which is exactly what someone reading it needs.
+ function showLogDetail(row) {
+ var badge = {error: 'bg-danger', warning: 'bg-warning text-dark'},
+ $dl = $('#task-log-detail'),
+ pairs = [
+ ['Time', $.escapeHtml(row.logtime || '')],
+ ['Host', row.hostid ?
+ '' + $.escapeHtml(row.hostname || '') + '' :
+ $.escapeHtml(row.hostname || '')],
+ ['Task', $.escapeHtml(String(row.taskid || '')) + ' — ' + $.escapeHtml(row.tasktypename || '')],
+ ['State at the time', $.escapeHtml(row.taskstatename || '')
+ + ' '],
+ ['Type', ''
+ + $.escapeHtml(row.logtype || '') + ''],
+ ['Recorded by', $.escapeHtml(row.createdBy || '')]
+ ],
+ html = '';
+ $.each(pairs, function(i, pair) {
+ html += '' + pair[0] + ''
+ + '' + pair[1] + '';
+ });
+ // A state change has no message at all, which is a fact worth showing
+ // rather than an empty box.
+ html += 'Message'
+ + (row.logtext ?
+ '' + $.escapeHtml(row.logtext) + '
' :
+ 'none')
+ + '';
+ $dl.html(html);
+ $('#task-log-modal').modal('show');
+ }
+
+ // Delegated, because the grid replaces its rows on every draw.
+ $(document).on('click', '#task-logs-table tbody tr', function(e) {
+ // A row carries links out to the host; let those win.
+ if ($(e.target).closest('a').length) {
+ return;
+ }
+ var row = panes.logs.table && panes.logs.table.row(this).data();
+ if (row) {
+ showLogDetail(row);
+ }
+ });
+
// ---------------------------------------------------------------
// PER-PANE ACTION BUTTONS (cancel / reload toggle)
//
diff --git a/packages/web/management/languages/de_DE.UTF-8/LC_MESSAGES/messages.po b/packages/web/management/languages/de_DE.UTF-8/LC_MESSAGES/messages.po
index c24d6f7b79..54a653be42 100644
--- a/packages/web/management/languages/de_DE.UTF-8/LC_MESSAGES/messages.po
+++ b/packages/web/management/languages/de_DE.UTF-8/LC_MESSAGES/messages.po
@@ -4505,6 +4505,10 @@ msgstr "Log-Viewer"
msgid "Log contents."
msgstr ""
+#, fuzzy
+msgid "Log entry"
+msgstr "Log-Viewer"
+
#, fuzzy
msgid "Log entry type filter"
msgstr "Drucker-Update fehlgeschlagen!"
diff --git a/packages/web/management/languages/en_US.UTF-8/LC_MESSAGES/messages.po b/packages/web/management/languages/en_US.UTF-8/LC_MESSAGES/messages.po
index 2a98e65c37..bd56b16d37 100644
--- a/packages/web/management/languages/en_US.UTF-8/LC_MESSAGES/messages.po
+++ b/packages/web/management/languages/en_US.UTF-8/LC_MESSAGES/messages.po
@@ -4503,6 +4503,10 @@ msgstr "Log Viewer"
msgid "Log contents."
msgstr ""
+#, fuzzy
+msgid "Log entry"
+msgstr "Log Viewer"
+
#, fuzzy
msgid "Log entry type filter"
msgstr "Printer update failed!"
diff --git a/packages/web/management/languages/es_ES.UTF-8/LC_MESSAGES/messages.po b/packages/web/management/languages/es_ES.UTF-8/LC_MESSAGES/messages.po
index ad0a33c0b0..ad2f6f693d 100644
--- a/packages/web/management/languages/es_ES.UTF-8/LC_MESSAGES/messages.po
+++ b/packages/web/management/languages/es_ES.UTF-8/LC_MESSAGES/messages.po
@@ -4614,6 +4614,10 @@ msgstr "FOG Visor de registro"
msgid "Log contents."
msgstr ""
+#, fuzzy
+msgid "Log entry"
+msgstr "FOG Visor de registro"
+
#, fuzzy
msgid "Log entry type filter"
msgstr "actualización de la impresora ha fallado!"
diff --git a/packages/web/management/languages/eu_ES.UTF-8/LC_MESSAGES/messages.po b/packages/web/management/languages/eu_ES.UTF-8/LC_MESSAGES/messages.po
index 4ebb418e75..8bb1014bfd 100644
--- a/packages/web/management/languages/eu_ES.UTF-8/LC_MESSAGES/messages.po
+++ b/packages/web/management/languages/eu_ES.UTF-8/LC_MESSAGES/messages.po
@@ -4506,6 +4506,10 @@ msgstr "Log-Viewer"
msgid "Log contents."
msgstr ""
+#, fuzzy
+msgid "Log entry"
+msgstr "Log-Viewer"
+
#, fuzzy
msgid "Log entry type filter"
msgstr "Drucker-Update fehlgeschlagen!"
diff --git a/packages/web/management/languages/fr_FR.UTF-8/LC_MESSAGES/messages.po b/packages/web/management/languages/fr_FR.UTF-8/LC_MESSAGES/messages.po
index 4aa046fefb..97e81bf711 100644
--- a/packages/web/management/languages/fr_FR.UTF-8/LC_MESSAGES/messages.po
+++ b/packages/web/management/languages/fr_FR.UTF-8/LC_MESSAGES/messages.po
@@ -4505,6 +4505,10 @@ msgstr "Log Viewer"
msgid "Log contents."
msgstr ""
+#, fuzzy
+msgid "Log entry"
+msgstr "Log Viewer"
+
#, fuzzy
msgid "Log entry type filter"
msgstr "mise à jour de l'imprimante a échoué!"
diff --git a/packages/web/management/languages/it_IT.UTF-8/LC_MESSAGES/messages.po b/packages/web/management/languages/it_IT.UTF-8/LC_MESSAGES/messages.po
index 2c1a97cb84..98921c22c4 100644
--- a/packages/web/management/languages/it_IT.UTF-8/LC_MESSAGES/messages.po
+++ b/packages/web/management/languages/it_IT.UTF-8/LC_MESSAGES/messages.po
@@ -4337,6 +4337,10 @@ msgstr "Log Viewer"
msgid "Log contents."
msgstr ""
+#, fuzzy
+msgid "Log entry"
+msgstr "Log Viewer"
+
#, fuzzy
msgid "Log entry type filter"
msgstr "Aggiornamento della stampante non è riuscito!"
diff --git a/packages/web/management/languages/ja_JP.UTF-8/LC_MESSAGES/messages.po b/packages/web/management/languages/ja_JP.UTF-8/LC_MESSAGES/messages.po
index 33edc47287..f6506b92b6 100644
--- a/packages/web/management/languages/ja_JP.UTF-8/LC_MESSAGES/messages.po
+++ b/packages/web/management/languages/ja_JP.UTF-8/LC_MESSAGES/messages.po
@@ -4293,6 +4293,10 @@ msgstr "ログビューアー"
msgid "Log contents."
msgstr ""
+#, fuzzy
+msgid "Log entry"
+msgstr "ログビューアー"
+
#, fuzzy
msgid "Log entry type filter"
msgstr "サイトの更新に失敗しました!"
diff --git a/packages/web/management/languages/messages.pot b/packages/web/management/languages/messages.pot
index 53af7ddf24..e0f9235f24 100644
--- a/packages/web/management/languages/messages.pot
+++ b/packages/web/management/languages/messages.pot
@@ -3802,6 +3802,9 @@ msgstr ""
msgid "Log contents."
msgstr ""
+msgid "Log entry"
+msgstr ""
+
msgid "Log entry type filter"
msgstr ""
diff --git a/packages/web/management/languages/pt_BR.UTF-8/LC_MESSAGES/messages.po b/packages/web/management/languages/pt_BR.UTF-8/LC_MESSAGES/messages.po
index 3d44662c95..d4d1e4792f 100644
--- a/packages/web/management/languages/pt_BR.UTF-8/LC_MESSAGES/messages.po
+++ b/packages/web/management/languages/pt_BR.UTF-8/LC_MESSAGES/messages.po
@@ -4504,6 +4504,10 @@ msgstr "Visualizador de log"
msgid "Log contents."
msgstr ""
+#, fuzzy
+msgid "Log entry"
+msgstr "Visualizador de log"
+
#, fuzzy
msgid "Log entry type filter"
msgstr "atualização da impressora falhou!"
diff --git a/packages/web/management/languages/zh_CN.UTF-8/LC_MESSAGES/messages.po b/packages/web/management/languages/zh_CN.UTF-8/LC_MESSAGES/messages.po
index 712c19d6ba..4f565e8e92 100644
--- a/packages/web/management/languages/zh_CN.UTF-8/LC_MESSAGES/messages.po
+++ b/packages/web/management/languages/zh_CN.UTF-8/LC_MESSAGES/messages.po
@@ -4504,6 +4504,10 @@ msgstr "日志查看器"
msgid "Log contents."
msgstr ""
+#, fuzzy
+msgid "Log entry"
+msgstr "日志查看器"
+
#, fuzzy
msgid "Log entry type filter"
msgstr "打印机更新失败!"
diff --git a/tests/task-log-view.test.php b/tests/task-log-view.test.php
index ed5a56f372..33a8b1ce99 100644
--- a/tests/task-log-view.test.php
+++ b/tests/task-log-view.test.php
@@ -115,6 +115,58 @@
. ' endpoint disagree about what is selected on arrival';
}
+// ------------------------------------------------------------ the typing
+
+// A column DEFAULT does not type these rows: FOGController::save() writes
+// every declared field, so a writer that sets no type stores ''. The model
+// has to supply it, or every state row written after schema 338 is missing
+// from the 'state' filter -- which is the one view built to show them.
+$model = file_get_contents($web . '/lib/fog/tasklog.class.php');
+if (!preg_match(
+ '#__construct.*?get\(\'type\'\).*?set\(\'type\', self::TYPE_STATE\)#s',
+ $model
+)) {
+ $fails[] = 'TaskLog does not default its own type, so every row written by'
+ . ' a caller that sets none stores an empty string and disappears from'
+ . ' the state filter';
+}
+$schema = file_get_contents($web . '/commons/schema.php');
+if (!preg_match(
+ "#UPDATE `taskLog`.*?SET `logType` = 'state'.*?WHERE `logType` = ''#s",
+ $schema
+)) {
+ $fails[] = 'no schema step retypes the rows written untyped before the'
+ . ' model was fixed, so they stay invisible to the state filter';
+}
+
+// ------------------------------------------------------------- the modal
+
+// The message column truncates, and a FOS report's value is its full text --
+// the script it came from and the arguments it was passed.
+if (false === strpos($page, "'task-log-modal'")) {
+ $fails[] = 'the logs pane has no detail modal, so a truncated message'
+ . ' cannot be read in full';
+}
+if (false === strpos($js, "#task-log-modal")
+ || false === strpos($js, "#task-logs-table tbody tr")
+) {
+ $fails[] = 'nothing opens the log detail modal from a row, so the markup'
+ . ' is emitted and unreachable';
+}
+if (false === strpos($js, "closest('a').length")) {
+ $fails[] = 'the row click does not defer to the links inside it, so'
+ . ' clicking through to a host opens the modal instead';
+}
+// Filled, not merely referenced: dropping the write leaves the modal showing
+// whatever the last click put there, which reads as the wrong row's detail
+// rather than as a bug.
+if (false === strpos($js, '$(\'#task-log-detail\')')
+ || false === strpos($js, '$dl.html(')
+) {
+ $fails[] = 'the modal is opened without being filled, so it shows the'
+ . ' previous row (or nothing) whatever was clicked';
+}
+
if ($fails) {
echo 'FAIL: ' . count($fails) . " problem(s):\n";
foreach ($fails as $f) {