Added a new `collapsible` property to GetLogs component that allows disabling the expandable header, useful for log viewers in dedicated pages and slide-overs. Applied this to Sentinel logs, Proxy logs, and Coolify Proxy log pages. Also improved the toolbar by moving the lines counter to the left side with an inline prefix label and repositioning the match counter next to it for better organization. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
325 lines
No EOL
19 KiB
PHP
325 lines
No EOL
19 KiB
PHP
<div class="{{ $collapsible ? 'my-4 border dark:border-coolgray-200 border-neutral-200' : '' }}">
|
|
<div id="screen" x-data="{
|
|
collapsible: {{ $collapsible ? 'true' : 'false' }},
|
|
expanded: {{ ($expandByDefault || !$collapsible) ? 'true' : 'false' }},
|
|
logsLoaded: {{ ($expandByDefault || !$collapsible) ? 'true' : 'false' }},
|
|
fullscreen: false,
|
|
alwaysScroll: false,
|
|
intervalId: null,
|
|
searchQuery: '',
|
|
renderTrigger: 0,
|
|
containerName: '{{ $container ?? "logs" }}',
|
|
makeFullscreen() {
|
|
this.fullscreen = !this.fullscreen;
|
|
if (this.fullscreen === false) {
|
|
this.alwaysScroll = false;
|
|
clearInterval(this.intervalId);
|
|
}
|
|
},
|
|
isScrolling: false,
|
|
toggleScroll() {
|
|
this.alwaysScroll = !this.alwaysScroll;
|
|
if (this.alwaysScroll) {
|
|
this.intervalId = setInterval(() => {
|
|
const logsContainer = document.getElementById('logsContainer');
|
|
if (logsContainer) {
|
|
this.isScrolling = true;
|
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
|
setTimeout(() => { this.isScrolling = false; }, 50);
|
|
}
|
|
}, 100);
|
|
} else {
|
|
clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
}
|
|
},
|
|
handleScroll(event) {
|
|
if (!this.alwaysScroll || this.isScrolling) return;
|
|
const el = event.target;
|
|
// Check if user scrolled away from the bottom
|
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
if (distanceFromBottom > 50) {
|
|
this.alwaysScroll = false;
|
|
clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
}
|
|
},
|
|
matchesSearch(line) {
|
|
if (!this.searchQuery.trim()) return true;
|
|
return line.toLowerCase().includes(this.searchQuery.toLowerCase());
|
|
},
|
|
decodeHtml(text) {
|
|
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
|
|
let decoded = text;
|
|
let prev = '';
|
|
let iterations = 0;
|
|
const maxIterations = 3; // Prevent DoS from deeply nested HTML entities
|
|
|
|
while (decoded !== prev && iterations < maxIterations) {
|
|
prev = decoded;
|
|
const doc = new DOMParser().parseFromString(decoded, 'text/html');
|
|
decoded = doc.documentElement.textContent;
|
|
iterations++;
|
|
}
|
|
return decoded;
|
|
},
|
|
renderHighlightedLog(el, text) {
|
|
const decoded = this.decodeHtml(text);
|
|
el.textContent = '';
|
|
|
|
if (!this.searchQuery.trim()) {
|
|
el.textContent = decoded;
|
|
return;
|
|
}
|
|
|
|
const query = this.searchQuery.toLowerCase();
|
|
const lowerText = decoded.toLowerCase();
|
|
let lastIndex = 0;
|
|
|
|
let index = lowerText.indexOf(query, lastIndex);
|
|
while (index !== -1) {
|
|
// Add text before match
|
|
if (index > lastIndex) {
|
|
el.appendChild(document.createTextNode(decoded.substring(lastIndex, index)));
|
|
}
|
|
// Add highlighted match
|
|
const mark = document.createElement('span');
|
|
mark.className = 'log-highlight';
|
|
mark.textContent = decoded.substring(index, index + this.searchQuery.length);
|
|
el.appendChild(mark);
|
|
|
|
lastIndex = index + this.searchQuery.length;
|
|
index = lowerText.indexOf(query, lastIndex);
|
|
}
|
|
|
|
// Add remaining text
|
|
if (lastIndex < decoded.length) {
|
|
el.appendChild(document.createTextNode(decoded.substring(lastIndex)));
|
|
}
|
|
},
|
|
getMatchCount() {
|
|
if (!this.searchQuery.trim()) return 0;
|
|
const logs = document.getElementById('logs');
|
|
if (!logs) return 0;
|
|
const lines = logs.querySelectorAll('[data-log-line]');
|
|
let count = 0;
|
|
lines.forEach(line => {
|
|
if (line.textContent.toLowerCase().includes(this.searchQuery.toLowerCase())) {
|
|
count++;
|
|
}
|
|
});
|
|
return count;
|
|
},
|
|
downloadLogs() {
|
|
const logs = document.getElementById('logs');
|
|
if (!logs) return;
|
|
const visibleLines = logs.querySelectorAll('[data-log-line]:not(.hidden)');
|
|
let content = '';
|
|
visibleLines.forEach(line => {
|
|
const text = line.textContent.replace(/\s+/g, ' ').trim();
|
|
if (text) {
|
|
content += text + String.fromCharCode(10);
|
|
}
|
|
});
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
|
|
a.download = this.containerName + '-logs-' + timestamp + '.txt';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
init() {
|
|
if (this.expanded) { this.$wire.getLogs(); }
|
|
// Re-render logs after Livewire updates
|
|
Livewire.hook('commit', ({ succeed }) => {
|
|
succeed(() => {
|
|
this.$nextTick(() => { this.renderTrigger++; });
|
|
});
|
|
});
|
|
}
|
|
}">
|
|
@if ($collapsible)
|
|
<div class="flex gap-2 items-center p-4 cursor-pointer select-none hover:bg-gray-50 dark:hover:bg-coolgray-200"
|
|
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(); logsLoaded = true; }">
|
|
<svg class="w-4 h-4 transition-transform" :class="expanded ? 'rotate-90' : ''" viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg">
|
|
<path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
|
</svg>
|
|
@if ($displayName)
|
|
<h4>{{ $displayName }}</h4>
|
|
@elseif ($resource?->type() === 'application' || str($resource?->type())->startsWith('standalone'))
|
|
<h4>{{ $container }}</h4>
|
|
@else
|
|
<h4>{{ str($container)->beforeLast('-')->headline() }}</h4>
|
|
@endif
|
|
@if ($pull_request)
|
|
<div>({{ $pull_request }})</div>
|
|
@endif
|
|
@if ($streamLogs)
|
|
<x-loading wire:poll.2000ms='getLogs(true)' />
|
|
@endif
|
|
</div>
|
|
@endif
|
|
<div x-show="expanded" {{ $collapsible ? 'x-collapse' : '' }}
|
|
:class="fullscreen ? 'fullscreen flex flex-col' : 'relative w-full {{ $collapsible ? 'py-4' : '' }} mx-auto'">
|
|
<div class="flex flex-col bg-white dark:text-white dark:bg-coolgray-100 dark:border-coolgray-300 border-neutral-200"
|
|
:class="fullscreen ? 'h-full' : 'border border-solid rounded-sm'">
|
|
<div
|
|
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
|
|
<div class="flex items-center gap-2">
|
|
<form wire:submit="getLogs(true)" class="relative flex items-center">
|
|
<span
|
|
class="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none">Lines:</span>
|
|
<input type="number" wire:model="numberOfLines" placeholder="100" min="1"
|
|
title="Number of Lines" {{ $streamLogs ? 'readonly' : '' }}
|
|
class="input input-sm w-24 pl-11 text-center dark:bg-coolgray-300" />
|
|
</form>
|
|
<span x-show="searchQuery.trim()" x-text="getMatchCount() + ' matches'"
|
|
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<div class="relative">
|
|
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
|
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
|
stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
|
</svg>
|
|
<input type="text" x-model="searchQuery" placeholder="Find in logs"
|
|
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-300" />
|
|
<button x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
|
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
|
stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<button wire:click="getLogs(true)" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
|
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50">
|
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
|
stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
|
</svg>
|
|
</button>
|
|
<button x-on:click="downloadLogs()" title="Download Logs"
|
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
|
stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
|
</svg>
|
|
</button>
|
|
<button wire:click="toggleTimestamps" title="Toggle Timestamps"
|
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $showTimeStamps ? '!text-warning' : '' }}">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
|
|
stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
</svg>
|
|
</button>
|
|
<button wire:click="toggleStreamLogs"
|
|
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
|
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $streamLogs ? '!text-warning' : '' }}">
|
|
@if ($streamLogs)
|
|
{{-- Pause icon --}}
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
|
|
fill="currentColor">
|
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
|
</svg>
|
|
@else
|
|
{{-- Play icon --}}
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
|
|
fill="currentColor">
|
|
<path d="M8 5v14l11-7L8 5z" />
|
|
</svg>
|
|
@endif
|
|
</button>
|
|
<button title="Follow Logs" :class="alwaysScroll ? '!text-warning' : ''"
|
|
x-on:click="toggleScroll"
|
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
|
stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
|
|
</svg>
|
|
</button>
|
|
<button title="Fullscreen" x-show="!fullscreen" x-on:click="makeFullscreen"
|
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<g fill="none">
|
|
<path
|
|
d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z" />
|
|
<path fill="currentColor"
|
|
d="M9.793 12.793a1 1 0 0 1 1.497 1.32l-.083.094L6.414 19H9a1 1 0 0 1 .117 1.993L9 21H4a1 1 0 0 1-.993-.883L3 20v-5a1 1 0 0 1 1.993-.117L5 15v2.586l4.793-4.793ZM20 3a1 1 0 0 1 .993.883L21 4v5a1 1 0 0 1-1.993.117L19 9V6.414l-4.793 4.793a1 1 0 0 1-1.497-1.32l.083-.094L17.586 5H15a1 1 0 0 1-.117-1.993L15 3h5Z" />
|
|
</g>
|
|
</svg>
|
|
</button>
|
|
<button title="Minimize" x-show="fullscreen" x-on:click="makeFullscreen"
|
|
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
|
stroke-width="2" d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="logsContainer" @scroll="handleScroll"
|
|
class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0 scrollbar"
|
|
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
|
@if ($outputs)
|
|
<div id="logs" class="font-mono max-w-full cursor-default">
|
|
<div x-show="searchQuery.trim() && getMatchCount() === 0"
|
|
class="text-gray-500 dark:text-gray-400 py-2">
|
|
No matches found.
|
|
</div>
|
|
@foreach (explode("\n", $outputs) as $line)
|
|
@php
|
|
// Skip empty lines
|
|
if (trim($line) === '') {
|
|
continue;
|
|
}
|
|
|
|
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
|
|
$timestamp = '';
|
|
$logContent = $line;
|
|
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z?\s*(.*)$/', $line, $matches)) {
|
|
$year = $matches[1];
|
|
$month = $matches[2];
|
|
$day = $matches[3];
|
|
$time = $matches[4];
|
|
$microseconds = isset($matches[5]) ? substr($matches[5], 0, 6) : '000000';
|
|
$logContent = $matches[6];
|
|
|
|
// Convert month number to abbreviated name
|
|
$monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
$monthName = $monthNames[(int)$month - 1] ?? $month;
|
|
|
|
// Format: 2025-Dec-04 09:44:58.198879
|
|
$timestamp = "{$year}-{$monthName}-{$day} {$time}.{$microseconds}";
|
|
}
|
|
|
|
@endphp
|
|
<div data-log-line data-log-content="{{ $line }}"
|
|
x-bind:class="{ 'hidden': !matchesSearch($el.dataset.logContent) }"
|
|
class="flex gap-2 hover:bg-gray-100 dark:hover:bg-coolgray-500">
|
|
@if ($timestamp && $showTimeStamps)
|
|
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
|
|
@endif
|
|
<span data-line-text="{{ $logContent }}"
|
|
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
|
|
class="whitespace-pre-wrap break-all"></span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<pre id="logs"
|
|
class="font-mono whitespace-pre-wrap break-all max-w-full">Refresh to get the logs...</pre>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div> |