switch to a new logviewer and upgraded php libraries

This commit is contained in:
Codex Agent
2025-12-08 18:45:34 +01:00
parent 4784c23e70
commit bc960dc22b
18 changed files with 860 additions and 842 deletions

View File

@@ -1,157 +0,0 @@
<?php
namespace App\Filament\SuperAdmin\Pages;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Support\Facades\File;
class LogViewerPage extends Page
{
protected static ?string $navigationLabel = 'Logs';
protected static ?string $title = 'Application Logs';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
protected string $view = 'filament.super-admin.pages.log-viewer';
public array $logFiles = [];
public string $selectedFile = '';
public string $filter = '';
public int $limit = 200;
public array $entries = [];
public function mount(): void
{
$this->logFiles = $this->discoverLogFiles();
$this->selectedFile = $this->logFiles[0]['name'] ?? '';
$this->refreshLines();
}
public function updatedSelectedFile(): void
{
$this->refreshLines();
}
public function updatedFilter(): void
{
$this->refreshLines();
}
public function updatedLimit(): void
{
$this->limit = max(50, min(1000, $this->limit));
$this->refreshLines();
}
protected function discoverLogFiles(): array
{
$paths = File::glob(storage_path('logs/*.log')) ?: [];
$files = collect($paths)
->map(fn (string $path) => [
'name' => basename($path),
'path' => $path,
'modified' => File::lastModified($path) ?: 0,
'size' => File::size($path) ?: 0,
])
->sortByDesc('modified')
->values()
->all();
return $files;
}
protected function refreshLines(): void
{
$path = storage_path('logs/'.$this->selectedFile);
if (! File::exists($path)) {
$this->entries = [];
return;
}
$rawLines = $this->tailFile($path, $this->limit * 2);
if ($this->filter !== '') {
$needle = mb_strtolower($this->filter);
$rawLines = array_values(array_filter(
$rawLines,
fn (string $line) => str_contains(mb_strtolower($line), $needle)
));
}
$rawLines = array_slice($rawLines, -$this->limit * 2);
$rawLines = $this->mergeStackTraces($rawLines);
$rawLines = array_slice(array_reverse($rawLines), 0, $this->limit);
$this->entries = array_map([$this, 'formatLine'], $rawLines);
}
/**
* Return last $lines lines of file. Simpler approach is fine for small logs in admin view.
*/
protected function tailFile(string $path, int $lines = 200): array
{
try {
$contents = File::get($path);
} catch (\Throwable) {
return [];
}
$all = preg_split('/\r\n|\r|\n/', (string) $contents) ?: [];
return array_slice($all, -$lines);
}
protected function mergeStackTraces(array $lines): array
{
$merged = [];
foreach ($lines as $line) {
$trimmed = ltrim($line);
$isStackLine = str_starts_with($trimmed, '#')
|| str_starts_with($trimmed, 'Stack trace:')
|| str_starts_with($line, ' ');
if ($isStackLine && ! empty($merged)) {
$merged[array_key_last($merged)] .= PHP_EOL.$line;
continue;
}
if ($line === '') {
continue;
}
$merged[] = $line;
}
return $merged;
}
protected function formatLine(string $line): array
{
$level = 'info';
$timestamp = null;
if (preg_match('/\\[(?<ts>[^\\]]+)\\]/', $line, $matches)) {
$timestamp = $matches['ts'] ?? null;
}
if (preg_match('/\\.(EMERGENCY|ALERT|CRITICAL|ERROR|WARNING|NOTICE|INFO|DEBUG)/i', $line, $matches)) {
$level = strtolower($matches[1]);
}
return [
'text' => $line,
'level' => $level,
'timestamp' => $timestamp,
];
}
}

View File

@@ -11,12 +11,14 @@ use App\Filament\Widgets\PlatformStatsWidget;
use App\Filament\Widgets\RevenueTrendWidget;
use App\Filament\Widgets\TopTenantsByRevenue;
use App\Filament\Widgets\TopTenantsByUploads;
use Boquizo\FilamentLogViewer\FilamentLogViewerPlugin;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Icons\Heroicon;
use Filament\Support\Colors\Color;
use Filament\Widgets;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@@ -39,6 +41,13 @@ class SuperAdminPanelProvider extends PanelProvider
->colors([
'primary' => Color::Pink,
])
->plugins([
FilamentLogViewerPlugin::make()
->navigationGroup('Platform')
->navigationLabel('Log Viewer')
->navigationIcon(Heroicon::OutlinedDocumentText)
->navigationSort(20),
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/SuperAdmin/Pages'), for: 'App\\Filament\\SuperAdmin\\Pages')
->pages([

View File

@@ -10,6 +10,7 @@
"dompdf/dompdf": "2.0",
"filament/filament": "~4.0",
"firebase/php-jwt": "^6.11",
"gboquizosanchez/filament-log-viewer": "*",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.37",

1290
composer.lock generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
function r({initialHeight:t,shouldAutosize:i,state:s}){return{state:s,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=t+"rem")},resize(){if(this.setInitialHeight(),this.$el.scrollHeight<=0)return;let e=this.$el.scrollHeight+"px";this.wrapperEl.style.height!==e&&(this.wrapperEl.style.height=e)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};
function r({initialHeight:i,shouldAutosize:s,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),s?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=i+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let e=this.$el.style.height;this.$el.style.height="0px";let t=this.$el.scrollHeight+"px";this.$el.style.height=e,this.wrapperEl.style.height!==t&&(this.wrapperEl.style.height=t)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};

View File

@@ -1 +1 @@
function u({activeTab:a,isTabPersistedInQueryString:e,livewireId:h,tab:o,tabQueryStringKey:s}){return{tab:o,init(){let t=this.getTabs(),i=new URLSearchParams(window.location.search);e&&i.has(s)&&t.includes(i.get(s))&&(this.tab=i.get(s)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[a-1]),Livewire.hook("commit",({component:r,commit:f,succeed:c,fail:l,respond:b})=>{c(({snapshot:d,effect:m})=>{this.$nextTick(()=>{if(r.id!==h)return;let n=this.getTabs();n.includes(this.tab)||(this.tab=n[a-1]??this.tab)})})})},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!e)return;let t=new URL(window.location.href);t.searchParams.set(s,this.tab),history.replaceState(null,document.title,t.toString())}}}export{u as default};
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let s=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,63 +0,0 @@
<x-filament::page>
@php($selectedMeta = collect($logFiles)->firstWhere('name', $selectedFile))
<div class="space-y-4">
<div class="grid gap-3 md:grid-cols-3">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase text-slate-500">Logdatei</label>
<select wire:model="selectedFile" class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-rose-300 focus:ring-1 focus:ring-rose-200">
@foreach($logFiles as $file)
<option value="{{ $file['name'] }}">
{{ $file['name'] }} ({{ number_format($file['size'] / 1024, 1) }} KB)
</option>
@endforeach
</select>
<p class="text-xs text-slate-500">Zuletzt aktualisiert: {{ $selectedMeta ? \Carbon\Carbon::createFromTimestamp($selectedMeta['modified'])->diffForHumans() : '—' }}</p>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase text-slate-500">Filter (Level/Text)</label>
<input type="text" wire:model.debounce.400ms="filter" placeholder="error|warning|payment" class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-rose-300 focus:ring-1 focus:ring-rose-200" />
<p class="text-xs text-slate-500">Einfache Teilstring-Suche. Kein Regex.</p>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase text-slate-500">Zeilenlimit</label>
<input type="number" min="50" max="1000" step="50" wire:model.lazy="limit" class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-rose-300 focus:ring-1 focus:ring-rose-200" />
<p class="text-xs text-slate-500">Letzte Zeilen (nach Filter).</p>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-2 text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<span>{{ $selectedFile ?: 'Keine Datei' }}</span>
<span>{{ $filter ? 'Gefiltert' : 'Ungefiltert' }} · {{ count($entries) }} Zeilen</span>
</div>
<div class="max-h-[70vh] overflow-y-auto">
<ul class="divide-y divide-slate-100 text-xs dark:divide-slate-800">
@forelse($entries as $entry)
<li class="px-4 py-2">
<div class="flex items-start gap-3">
<span @class([
'inline-flex min-w-[64px] items-center justify-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200' => $entry['level'] === 'error' || $entry['level'] === 'critical',
'bg-amber-100 text-amber-900 dark:bg-amber-500/20 dark:text-amber-100' => $entry['level'] === 'warning',
'bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-100' => $entry['level'] === 'info',
'bg-slate-200 text-slate-800 dark:bg-slate-700 dark:text-slate-200' => ! in_array($entry['level'], ['error','critical','warning','info']),
])>
{{ strtoupper($entry['level']) }}
</span>
<div class="flex-1 space-y-1">
@if($entry['timestamp'])
<p class="text-[11px] text-slate-500 dark:text-slate-400">{{ $entry['timestamp'] }}</p>
@endif
<p class="whitespace-pre-wrap leading-relaxed text-slate-800 dark:text-slate-100">{{ $entry['text'] }}</p>
</div>
</div>
</li>
@empty
<li class="px-4 py-3 text-slate-500 dark:text-slate-400">Keine Logeinträge gefunden.</li>
@endforelse
</ul>
</div>
</div>
</div>
</x-filament::page>