diff --git a/resources/js/runtime-logs.js b/resources/js/runtime-logs.js index e34f1a0f6b..16d4e6e1ff 100644 --- a/resources/js/runtime-logs.js +++ b/resources/js/runtime-logs.js @@ -45,7 +45,28 @@ export function isExpandableLog(content, truncated) { return truncated || formatLogDetails(content) !== content; } +const LEVEL_FIELD = /\b(?:level|lvl|severity|loglevel)"?\s*[=:]\s*"?([a-z]+)\b/; +// A level word at the start of the line, after timestamps: `2026-10-30t16:35:49z info msg`, `[debug] msg`. +const LEADING_LEVEL = /^(?:\S*\d\S*\s+){0,3}[[(<]?([a-z]+)[\])>:]?(?:\s|$)/; +const LEVEL_NAMES = { + error: ['error', 'err', 'fatal', 'panic', 'critical', 'crit', 'alert', 'emerg', 'emergency'], + warning: ['warn', 'warning', 'wrn'], + debug: ['debug', 'dbg', 'trace', 'verbose'], + info: ['info', 'inf', 'information', 'notice'], +}; + +/** + * Prefer the declared level (`level=debug`, `"level":"debug"`, a leading + * `INFO`) over keywords, so `INFO ... 0 error events` or `level=debug err=EOF` + * is not shown as an error. + */ export function getLogLevel(content) { + for (const pattern of [LEVEL_FIELD, LEADING_LEVEL]) { + const declared = content.match(pattern)?.[1]; + for (const [level, names] of Object.entries(LEVEL_NAMES)) { + if (names.includes(declared)) return level; + } + } if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) return 'error'; if (/\b(warn|warning|wrn|caution)\b/.test(content)) return 'warning'; if (/\b(debug|dbg|trace|verbose)\b/.test(content)) return 'debug'; diff --git a/resources/js/runtime-logs.test.js b/resources/js/runtime-logs.test.js index c79b6cf3aa..37270f3d1c 100644 --- a/resources/js/runtime-logs.test.js +++ b/resources/js/runtime-logs.test.js @@ -43,6 +43,14 @@ test('detects log levels by keyword', () => { assert.equal(getLogLevel('warning: disk'), 'warning'); assert.equal(getLogLevel('trace id=1'), 'debug'); assert.equal(getLogLevel('ready'), 'info'); + assert.equal(getLogLevel('time=2026-10-30t16:35:49z level=debug msg="read failed" err=eof'), 'debug'); + assert.equal(getLogLevel('{"level":"info","msg":"conn closed","err":"eof"}'), 'info'); + assert.equal(getLogLevel('level=error msg="db down"'), 'error'); + assert.equal(getLogLevel('{"level":50,"msg":"failed"}'), 'error'); + assert.equal(getLogLevel('2026-10-29t03:17:00.849341z info logs: pruned 840 requests, 0 error events'), 'info'); + assert.equal(getLogLevel('2026/10/30 16:35:49 [debug] read failed'), 'debug'); + assert.equal(getLogLevel('warn: retry failed'), 'warning'); + assert.equal(getLogLevel('connection failed'), 'error'); }); test('filters by level and case-insensitive query', () => { diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php index 6b41fcc983..acecb157ba 100644 --- a/resources/views/livewire/analytics.blade.php +++ b/resources/views/livewire/analytics.blade.php @@ -244,7 +244,8 @@ $appListboxOptions = array_merge( icon-name="unordered-list" /> @else @php $maxAppRequests = max(1, (int) collect($topApps)->max('requests')); @endphp -
+ {{-- Keyed so the lazy-load morph does not reuse the placeholder skeleton nodes. --}} +
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Application / service', 'text' => true, 'class' => 'min-w-0 flex-1']) diff --git a/resources/views/livewire/traffic/_breakdown-section.blade.php b/resources/views/livewire/traffic/_breakdown-section.blade.php index 212e86833e..5dbb0302ab 100644 --- a/resources/views/livewire/traffic/_breakdown-section.blade.php +++ b/resources/views/livewire/traffic/_breakdown-section.blade.php @@ -21,7 +21,8 @@ @else -
+ {{-- Keyed so the lazy-load morph does not reuse the placeholder skeleton nodes. --}} +
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Name', 'text' => true, 'class' => 'min-w-0 flex-1']) diff --git a/resources/views/livewire/traffic/_hosts-list.blade.php b/resources/views/livewire/traffic/_hosts-list.blade.php index 9785bd38eb..b02a9f2300 100644 --- a/resources/views/livewire/traffic/_hosts-list.blade.php +++ b/resources/views/livewire/traffic/_hosts-list.blade.php @@ -16,7 +16,8 @@ @else -
+ {{-- Keyed so the lazy-load morph does not reuse the placeholder skeleton nodes. --}} +
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Host', 'text' => true, 'class' => 'min-w-0 flex-1']) diff --git a/resources/views/livewire/traffic/_paths-list.blade.php b/resources/views/livewire/traffic/_paths-list.blade.php index d997c033ca..b77d1f8e30 100644 --- a/resources/views/livewire/traffic/_paths-list.blade.php +++ b/resources/views/livewire/traffic/_paths-list.blade.php @@ -20,7 +20,10 @@ @else -
+ {{-- Keyed so the lazy-load morph inserts fresh nodes instead of reusing the + placeholder skeleton; reused nodes keep their old Alpine scope and the + pager and sort buttons stop working. --}} +
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Path', 'text' => true, 'class' => 'min-w-0 flex-1'])