festbench-web/resources/views/livewire/summary/index.blade.php
PeterChrz 700bdae959
feat: replace search/select filters with 5 multi-select checkbox dropdowns
Remove the free-text search input (exact-string LIKE was misleading) and
the single-value Model/Suite selects. Replace with five checkbox
multi-select dropdowns in display order: GPU, Model, Quant, Inference
engine, Suite.

Matching semantics:
  - OR within each filter control (selecting two GPUs shows either)
  - AND across controls (GPU + Model + Quant + Engine + Suite)
  - Suite selection is inclusive: a band running any selected suite
    matches, including incomplete bands missing some selected suites

Each dropdown shows a count badge when active and a checkmark per selected
item. Pagination resets on every filter change. The cache key already
includes the filters array so filtered results are cached independently.
2026-10-05 00:37:33 -04:00

382 lines
No EOL
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\BenchmarkRun;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
public array $filters = [
'gpu' => [],
'model' => [],
'quant' => [],
'engine' => [],
'suite' => [],
];
public function updatedFilters(): void
{
$this->resetPage();
}
public function resetFilters(): void
{
$this->filters = [
'gpu' => [],
'model' => [],
'quant' => [],
'engine' => [],
'suite' => [],
];
$this->resetPage();
}
public function toggleFilter(string $field, string $value): void
{
if (! array_key_exists($field, $this->filters)) {
return;
}
$this->filters[$field] = array_values(array_unique(
in_array($value, $this->filters[$field], true)
? array_diff($this->filters[$field], [$value])
: array_merge($this->filters[$field], [$value])
));
$this->resetPage();
}
public function filterLabel(string $field): string
{
$count = count($this->filters[$field] ?? []);
return $count === 0 ? '' : ' ('.$count.')';
}
#[Computed]
public function sessions()
{
$ttl = (int) config('festbench.home_cache_ttl', 300);
$user = auth()->user();
// Guest views are shared-cacheable; logged-in views may contain the
// user's private bands, so they get a user-scoped key.
$cacheKey = 'summary:'.($user?->id ?? 'guest').':'.md5(json_encode([$this->filters, $this->getPage()]));
return Cache::remember($cacheKey, $ttl, function () use ($user) {
$bands = $this->bandQuery($user)->paginate(10);
$runIds = $bands->getCollection()->map(fn ($b) => $b->id)->all();
$suiteRows = $runIds === []
? collect()
: \Illuminate\Support\Facades\DB::table('session_suite_rows')
->whereIn('session_summary_id', $runIds)
->orderBy('sequence')
->get()
->groupBy('session_summary_id');
$bands->getCollection()->transform(fn ($band) => (object) [
'band' => $band,
'suites' => $suiteRows->get($band->id, collect()),
]);
return $bands;
});
}
private function bandQuery($user)
{
return \Illuminate\Support\Facades\DB::table('session_summaries')
->where(function ($q) use ($user) {
$q->where('visibility', 'public');
if ($user !== null) {
$q->orWhere(function ($own) use ($user) {
$own->where('visibility', 'private')->where('user_id', $user->id);
});
}
})
->when($this->filters['gpu'], fn ($q, array $gpus) => $q->whereIn('gpu', $gpus))
->when($this->filters['model'], fn ($q, array $models) => $q->whereIn('model_id', $models))
->when($this->filters['quant'], fn ($q, array $quants) => $q->whereIn('model_quant', $quants))
->when($this->filters['engine'], fn ($q, array $engines) => $q->whereIn('engine', $engines))
->when($this->filters['suite'], fn ($q, array $suites) => $q->whereExists(function ($sub) use ($suites) {
$sub->select(\Illuminate\Support\Facades\DB::raw(1))
->from('session_suite_rows')
->whereColumn('session_suite_rows.session_summary_id', 'session_summaries.id')
->whereIn('session_suite_rows.suite', $suites);
}))
->orderByDesc('ran_at')
->orderBy('sequence');
}
#[Computed]
public function gpus(): array
{
return Cache::remember('summary:gpus', 300, function () {
return \Illuminate\Support\Facades\DB::table('session_summaries')
->where('visibility', 'public')
->whereNotNull('gpu')
->distinct()->orderBy('gpu')->pluck('gpu')->all();
});
}
#[Computed]
public function models(): array
{
return Cache::remember('summary:models', 300, function () {
return \Illuminate\Support\Facades\DB::table('session_summaries')
->where('visibility', 'public')
->whereNotNull('model_id')
->distinct()->orderBy('model_id')->pluck('model_id')->all();
});
}
#[Computed]
public function quants(): array
{
return Cache::remember('summary:quants', 300, function () {
return \Illuminate\Support\Facades\DB::table('session_summaries')
->where('visibility', 'public')
->whereNotNull('model_quant')
->distinct()->orderBy('model_quant')->pluck('model_quant')->all();
});
}
#[Computed]
public function engines(): array
{
return Cache::remember('summary:engines', 300, function () {
return \Illuminate\Support\Facades\DB::table('session_summaries')
->where('visibility', 'public')
->whereNotNull('engine')
->distinct()->orderBy('engine')->pluck('engine')->all();
});
}
#[Computed]
public function suites(): array
{
return Cache::remember('summary:suites', 300, function () {
return \Illuminate\Support\Facades\DB::table('session_suite_rows')
->join('session_summaries', 'session_summaries.id', '=', 'session_suite_rows.session_summary_id')
->where('session_summaries.visibility', 'public')
->distinct()->orderBy('suite')->pluck('suite')->all();
});
}
};
?>
<div>
<div class="flex items-center justify-between">
<div>
<flux:heading size="xl">{{ __('FestBench - Benchmarks') }}</flux:heading>
<flux:subheading>{{ __('GPU, model, suite scores — click a suite line for per-task detail.') }}</flux:subheading>
</div>
<flux:button variant="ghost" icon="table-cells" href="{{ route('results') }}" wire:navigate>{{ __('All task rows') }}</flux:button>
</div>
<div class="mt-6 flex flex-wrap items-end gap-3">
@php
$filterGroups = [
['field' => 'gpu', 'label' => __('GPU'), 'options' => $this->gpus],
['field' => 'model', 'label' => __('Model'), 'options' => $this->models],
['field' => 'quant', 'label' => __('Quant'), 'options' => $this->quants],
['field' => 'engine', 'label' => __('Inference engine'), 'options' => $this->engines],
['field' => 'suite', 'label' => __('Suite'), 'options' => $this->suites],
];
@endphp
@foreach ($filterGroups as $group)
<flux:dropdown position="bottom" align="start">
<flux:button size="sm" variant="outline" class="justify-between {{ count($this->filters[$group['field']]) > 0 ? 'border-sky-400 text-sky-600 dark:border-sky-500 dark:text-sky-400' : '' }}">
{{ $group['label'] }}{{ $this->filterLabel($group['field']) }}
<flux:icon.chevron-down variant="mini" />
</flux:button>
<flux:menu class="min-w-48 max-h-72 overflow-y-auto p-1">
@foreach ($group['options'] as $option)
@php $checked = in_array($option, $this->filters[$group['field']], true); @endphp
<flux:menu.item
wire:click="toggleFilter('{{ $group['field'] }}', '{{ addslashes($option) }}')"
class="cursor-pointer"
>
<span class="flex items-center gap-2">
@if ($checked)
<flux:icon.check variant="mini" class="size-4 text-sky-500" />
@else
<span class="size-4"></span>
@endif
<span class="text-sm">{{ $option }}</span>
</span>
</flux:menu.item>
@endforeach
</flux:menu>
</flux:dropdown>
@endforeach
<flux:button size="sm" variant="ghost" wire:click="resetFilters" icon="x-mark">{{ __('Clear') }}</flux:button>
</div>
@if ($this->sessions->count() > 0)
<div class="mt-6 flex flex-col gap-4">
@foreach ($this->sessions as $session)
<flux:card class="!p-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<flux:icon name="cpu" class="size-4 text-zinc-500" />
<span class="text-sm font-semibold">{{ $session->band->gpu ?? 'unknown GPU' }}</span>
@if ($session->band->gpu_count)
<flux:badge size="xs" color="zinc">×{{ $session->band->gpu_count }}</flux:badge>
@endif
<span class="text-sm">{{ $session->band->model_id ?? 'unknown model' }}</span>
@if ($session->band->label)
<flux:badge size="xs" color="amber">{{ $session->band->label }}</flux:badge>
@endif
@if ($session->band->model_quant)
<flux:badge size="xs" color="zinc">{{ $session->band->model_quant }}</flux:badge>
@endif
@if ($session->band->engine)
<flux:badge size="xs" color="sky">{{ $session->band->engine }}</flux:badge>
@endif
<flux:spacer />
<span class="text-sm text-zinc-500">{{ $session->band->runner_name ?? 'anonymous' }}</span>
<span class="text-xs text-zinc-400">{{ optional($session->band->ran_at)->format('M j Y') }}</span>
</div>
@php
$devlogicSuites = ['softbench', 'prosebench', 'psychbench', 'depthsweep'];
$sysopsSuites = ['infrabench', 'scenariobench'];
$devlogicRows = $session->suites->filter(fn ($r) => in_array($r->suite, $devlogicSuites));
$sysopsRows = $session->suites->filter(fn ($r) => in_array($r->suite, $sysopsSuites));
@endphp
{{-- Devlogic section: total strip + column headers + suite rows --}}
@if ($devlogicRows->isNotEmpty() && $session->band->devlogic_tps !== null)
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 border-l-2 border-sky-400 bg-sky-50/50 px-4 py-2 dark:border-sky-500 dark:bg-sky-950/20">
<span class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400">{{ __('Devlogic total') }}</span>
<span class="text-sm">
<span class="font-mono font-semibold">{{ number_format($session->band->devlogic_tps, 1) }}</span>
<span class="text-xs text-zinc-400">{{ __('tok/s') }}</span>
</span>
<span class="text-sm">
<span class="font-mono font-semibold">{{ number_format($session->band->devlogic_tokens) }}</span>
<span class="text-xs text-zinc-400">{{ __('tokens') }}</span>
</span>
<span class="text-sm">
<flux:badge size="xs" color="emerald">✔ {{ $session->band->devlogic_passed }}</flux:badge>
<flux:badge size="xs" color="{{ $session->band->devlogic_failed > 0 ? 'rose' : 'zinc' }}">✘ {{ $session->band->devlogic_failed }}</flux:badge>
</span>
</div>
<div class="hidden px-4 pt-2 md:grid md:grid-cols-[7.5rem_4.5rem_7rem_15rem_6rem_1fr] md:gap-x-4 md:items-center">
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Benchmark') }}</span>
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Thinking') }}</span>
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Result') }}</span>
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Speed (tok/s)') }} <span class="font-normal normal-case">{{ __('weighted average') }}</span></span>
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Tokens') }}</span>
<span></span>
</div>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
@foreach ($devlogicRows as $row)
@php
$detailUrl = $row->suite === 'scenariobench'
? route('sessions.scenarios', $session->band->id)
: route('runs.show', $row->run_id) . ($row->level && $row->suite === 'depthsweep' ? '?level=' . $row->level : '');
@endphp
<a href="{{ $detailUrl }}" wire:navigate
class="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2.5 hover:bg-zinc-50 dark:hover:bg-zinc-800/60 md:grid md:grid-cols-[7.5rem_4.5rem_7rem_15rem_6rem_1fr] md:gap-x-4 md:items-center">
<span><flux:badge size="xs">{{ $row->suite }}</flux:badge></span>
<span>
@if ($row->level)
<flux:badge size="xs" color="sky">{{ $row->level }}</flux:badge>
@endif
</span>
<span class="flex items-center gap-1.5">
<flux:badge size="xs" color="emerald">✔ {{ $row->passed }}</flux:badge>
<flux:badge size="xs" color="{{ $row->failed > 0 ? 'rose' : 'zinc' }}">✘ {{ $row->failed }}</flux:badge>
</span>
<span>
@if ($row->weighted_user_tps !== null)
<span class="font-mono text-sm">{{ number_format($row->weighted_user_tps, 1) }}</span>
@elseif ($row->gen_tps !== null)
<span class="font-mono text-sm">{{ number_format($row->gen_tps, 1) }} <span class="text-xs text-zinc-400">{{ __('gen') }}</span></span>
@endif
</span>
<span class="font-mono text-sm">{{ number_format($row->total_tokens) }}</span>
<span class="text-xs text-zinc-400 md:justify-self-end">{{ __('per-task detail') }} →</span>
</a>
@endforeach
</div>
@endif
{{-- Sysops section: total strip + column headers + suite rows --}}
@if ($sysopsRows->isNotEmpty() && $session->band->sysops_tps !== null)
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 border-l-2 border-amber-400 bg-amber-50/50 px-4 py-2 dark:border-amber-500 dark:bg-amber-950/20">
<span class="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">{{ __('Sysops total') }}</span>
<span class="text-sm">
<span class="font-mono font-semibold">{{ number_format($session->band->sysops_tps, 1) }}</span>
<span class="text-xs text-zinc-400">{{ __('tok/s') }}</span>
</span>
<span class="text-sm">
<span class="font-mono font-semibold">{{ number_format($session->band->sysops_tokens) }}</span>
<span class="text-xs text-zinc-400">{{ __('tokens') }}</span>
</span>
<span class="text-sm">
<flux:badge size="xs" color="emerald">✔ {{ $session->band->sysops_passed }}</flux:badge>
<flux:badge size="xs" color="{{ $session->band->sysops_failed > 0 ? 'rose' : 'zinc' }}">✘ {{ $session->band->sysops_failed }}</flux:badge>
</span>
</div>
<div class="hidden px-4 pt-2 md:grid md:grid-cols-[7.5rem_4.5rem_7rem_15rem_6rem_1fr] md:gap-x-4 md:items-center">
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Benchmark') }}</span>
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Thinking') }}</span>
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Result') }}</span>
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Speed (tok/s)') }} <span class="font-normal normal-case">{{ __('weighted average') }}</span></span>
<span class="text-[10px] font-semibold uppercase tracking-wider text-zinc-400">{{ __('Tokens') }}</span>
<span></span>
</div>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
@foreach ($sysopsRows as $row)
@php
$detailUrl = $row->suite === 'scenariobench'
? route('sessions.scenarios', $session->band->id)
: route('runs.show', $row->run_id) . ($row->level && $row->suite === 'depthsweep' ? '?level=' . $row->level : '');
@endphp
<a href="{{ $detailUrl }}" wire:navigate
class="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2.5 hover:bg-zinc-50 dark:hover:bg-zinc-800/60 md:grid md:grid-cols-[7.5rem_4.5rem_7rem_15rem_6rem_1fr] md:gap-x-4 md:items-center">
<span><flux:badge size="xs">{{ $row->suite }}</flux:badge></span>
<span>
@if ($row->level)
<flux:badge size="xs" color="sky">{{ $row->level }}</flux:badge>
@endif
</span>
<span class="flex items-center gap-1.5">
<flux:badge size="xs" color="emerald">✔ {{ $row->passed }}</flux:badge>
<flux:badge size="xs" color="{{ $row->failed > 0 ? 'rose' : 'zinc' }}">✘ {{ $row->failed }}</flux:badge>
</span>
<span>
@if ($row->weighted_user_tps !== null)
<span class="font-mono text-sm">{{ number_format($row->weighted_user_tps, 1) }}</span>
@elseif ($row->gen_tps !== null)
<span class="font-mono text-sm">{{ number_format($row->gen_tps, 1) }} <span class="text-xs text-zinc-400">{{ __('gen') }}</span></span>
@endif
</span>
<span class="font-mono text-sm">{{ number_format($row->total_tokens) }}</span>
<span class="text-xs text-zinc-400 md:justify-self-end">{{ __('per-task detail') }} →</span>
</a>
@endforeach
</div>
@endif
</flux:card>
@endforeach
</div>
<div class="mt-4">
{{ $this->sessions->links() }}
</div>
@else
<div class="mt-6">
<flux:callout variant="brand">
<flux:callout.heading>{{ __('No sessions') }}</flux:callout.heading>
<flux:callout.text>{{ __('No benchmark sessions match the current filters.') }}</flux:callout.text>
</flux:callout>
</div>
@endif
</div>