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.
This commit is contained in:
parent
895a764b88
commit
700bdae959
2 changed files with 234 additions and 34 deletions
|
|
@ -11,17 +11,52 @@
|
||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
public array $filters = [
|
public array $filters = [
|
||||||
'search' => null,
|
'gpu' => [],
|
||||||
'model' => null,
|
'model' => [],
|
||||||
'suite' => null,
|
'quant' => [],
|
||||||
|
'engine' => [],
|
||||||
|
'suite' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function updatedFilters(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
public function resetFilters(): void
|
public function resetFilters(): void
|
||||||
{
|
{
|
||||||
$this->filters = ['search' => null, 'model' => null, 'suite' => null];
|
$this->filters = [
|
||||||
|
'gpu' => [],
|
||||||
|
'model' => [],
|
||||||
|
'quant' => [],
|
||||||
|
'engine' => [],
|
||||||
|
'suite' => [],
|
||||||
|
];
|
||||||
$this->resetPage();
|
$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]
|
#[Computed]
|
||||||
public function sessions()
|
public function sessions()
|
||||||
{
|
{
|
||||||
|
|
@ -63,34 +98,64 @@ private function bandQuery($user)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
->when($this->filters['search'], fn ($q, $search) => $q->where(function ($q) use ($search) {
|
->when($this->filters['gpu'], fn ($q, array $gpus) => $q->whereIn('gpu', $gpus))
|
||||||
$q->where('label', 'like', "%{$search}%")
|
->when($this->filters['model'], fn ($q, array $models) => $q->whereIn('model_id', $models))
|
||||||
->orWhere('model_id', 'like', "%{$search}%")
|
->when($this->filters['quant'], fn ($q, array $quants) => $q->whereIn('model_quant', $quants))
|
||||||
->orWhere('gpu', 'like', "%{$search}%")
|
->when($this->filters['engine'], fn ($q, array $engines) => $q->whereIn('engine', $engines))
|
||||||
->orWhere('runner_name', 'like', "%{$search}%");
|
->when($this->filters['suite'], fn ($q, array $suites) => $q->whereExists(function ($sub) use ($suites) {
|
||||||
}))
|
|
||||||
->when($this->filters['model'], fn ($q, $model) => $q->where('model_id', $model))
|
|
||||||
// Suite filter: keep bands containing the chosen suite
|
|
||||||
->when($this->filters['suite'], fn ($q, $suite) => $q->whereExists(function ($sub) use ($suite) {
|
|
||||||
$sub->select(\Illuminate\Support\Facades\DB::raw(1))
|
$sub->select(\Illuminate\Support\Facades\DB::raw(1))
|
||||||
->from('session_suite_rows')
|
->from('session_suite_rows')
|
||||||
->whereColumn('session_suite_rows.session_summary_id', 'session_summaries.id')
|
->whereColumn('session_suite_rows.session_summary_id', 'session_summaries.id')
|
||||||
->where('session_suite_rows.suite', $suite);
|
->whereIn('session_suite_rows.suite', $suites);
|
||||||
}))
|
}))
|
||||||
->orderByDesc('ran_at')
|
->orderByDesc('ran_at')
|
||||||
->orderBy('sequence');
|
->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]
|
#[Computed]
|
||||||
public function models(): array
|
public function models(): array
|
||||||
{
|
{
|
||||||
return Cache::remember('summary:models', 300, function () {
|
return Cache::remember('summary:models', 300, function () {
|
||||||
return \Illuminate\Support\Facades\DB::table('session_summaries')
|
return \Illuminate\Support\Facades\DB::table('session_summaries')
|
||||||
->where('visibility', 'public')
|
->where('visibility', 'public')
|
||||||
|
->whereNotNull('model_id')
|
||||||
->distinct()->orderBy('model_id')->pluck('model_id')->all();
|
->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]
|
#[Computed]
|
||||||
public function suites(): array
|
public function suites(): array
|
||||||
{
|
{
|
||||||
|
|
@ -114,28 +179,43 @@ public function suites(): array
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap items-end gap-3">
|
<div class="mt-6 flex flex-wrap items-end gap-3">
|
||||||
<flux:input
|
@php
|
||||||
wire:model.live.debounce.300ms="filters.search"
|
$filterGroups = [
|
||||||
:label="__('Search')"
|
['field' => 'gpu', 'label' => __('GPU'), 'options' => $this->gpus],
|
||||||
:placeholder="__('Session, model, GPU or runner…')"
|
['field' => 'model', 'label' => __('Model'), 'options' => $this->models],
|
||||||
size="sm"
|
['field' => 'quant', 'label' => __('Quant'), 'options' => $this->quants],
|
||||||
class="w-64"
|
['field' => 'engine', 'label' => __('Inference engine'), 'options' => $this->engines],
|
||||||
clearable
|
['field' => 'suite', 'label' => __('Suite'), 'options' => $this->suites],
|
||||||
/>
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
<flux:select wire:model.live="filters.model" :label="__('Model')" size="sm" class="w-56">
|
@foreach ($filterGroups as $group)
|
||||||
<flux:select.option value="">{{ __('All models') }}</flux:select.option>
|
<flux:dropdown position="bottom" align="start">
|
||||||
@foreach ($this->models as $model)
|
<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' : '' }}">
|
||||||
<flux:select.option value="{{ $model }}">{{ $model }}</flux:select.option>
|
{{ $group['label'] }}{{ $this->filterLabel($group['field']) }}
|
||||||
@endforeach
|
<flux:icon.chevron-down variant="mini" />
|
||||||
</flux:select>
|
</flux:button>
|
||||||
|
|
||||||
<flux:select wire:model.live="filters.suite" :label="__('Suite')" size="sm" class="w-44">
|
<flux:menu class="min-w-48 max-h-72 overflow-y-auto p-1">
|
||||||
<flux:select.option value="">{{ __('All suites') }}</flux:select.option>
|
@foreach ($group['options'] as $option)
|
||||||
@foreach ($this->suites as $suite)
|
@php $checked = in_array($option, $this->filters[$group['field']], true); @endphp
|
||||||
<flux:select.option value="{{ $suite }}">{{ $suite }}</flux:select.option>
|
<flux:menu.item
|
||||||
@endforeach
|
wire:click="toggleFilter('{{ $group['field'] }}', '{{ addslashes($option) }}')"
|
||||||
</flux:select>
|
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>
|
<flux:button size="sm" variant="ghost" wire:click="resetFilters" icon="x-mark">{{ __('Clear') }}</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use App\Models\SoftbenchResult;
|
use App\Models\SoftbenchResult;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
test('festbench:summary splits runs by label into separate bands', function () {
|
test('festbench:summary splits runs by label into separate bands', function () {
|
||||||
$me = User::factory()->create(['name' => 'peterZ']);
|
$me = User::factory()->create(['name' => 'peterZ']);
|
||||||
|
|
@ -158,3 +159,122 @@
|
||||||
->assertSee('All Task Rows')
|
->assertSee('All Task Rows')
|
||||||
->assertSee('flat-task');
|
->assertSee('flat-task');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('multi-filter: suite filter returns bands containing any selected suite', function () {
|
||||||
|
$me = User::factory()->create();
|
||||||
|
|
||||||
|
$soft = BenchmarkRun::factory()->create([
|
||||||
|
'suite' => 'softbench', 'label' => 'soft-only', 'model_id' => 'm', 'model_quant' => 'Q5_K_M',
|
||||||
|
'gpu' => 'GPU1', 'gpu_count' => 1, 'engine' => 'llama.cpp', 'user_id' => $me->id, 'visibility' => 'public',
|
||||||
|
'ran_at' => '2026-09-03 12:00:00',
|
||||||
|
]);
|
||||||
|
SoftbenchResult::factory()->create(['benchmark_run_id' => $soft->id, 'ok' => true, 'user_tps' => 50.0, 'total' => 100, 'level' => 'off']);
|
||||||
|
|
||||||
|
$infra = BenchmarkRun::factory()->create([
|
||||||
|
'suite' => 'infrabench', 'label' => 'infra-only', 'model_id' => 'm', 'model_quant' => 'Q5_K_M',
|
||||||
|
'gpu' => 'GPU1', 'gpu_count' => 1, 'engine' => 'llama.cpp', 'user_id' => $me->id, 'visibility' => 'public',
|
||||||
|
'ran_at' => '2026-09-03 12:05:00',
|
||||||
|
]);
|
||||||
|
InfrabenchResult::factory()->create(['benchmark_run_id' => $infra->id, 'ok' => true, 'user_tps' => 30.0, 'total' => 100, 'level' => 'low']);
|
||||||
|
|
||||||
|
Artisan::call('festbench:summary');
|
||||||
|
|
||||||
|
// No filter: both bands visible.
|
||||||
|
Volt::test('summary.index')->assertSee('soft-only')->assertSee('infra-only');
|
||||||
|
|
||||||
|
// Filter to infrabench: only the infra band should match.
|
||||||
|
Volt::test('summary.index', ['filters' => [
|
||||||
|
'gpu' => [], 'model' => [], 'quant' => [], 'engine' => [], 'suite' => ['infrabench'],
|
||||||
|
]])
|
||||||
|
->assertDontSee('soft-only')
|
||||||
|
->assertSee('infra-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-filter: AND across categories, OR within a category', function () {
|
||||||
|
$me = User::factory()->create();
|
||||||
|
|
||||||
|
// Band A: GPU1 + model-A + Q5_K_M + llama.cpp
|
||||||
|
$a = BenchmarkRun::factory()->create([
|
||||||
|
'suite' => 'softbench', 'label' => 'band-a', 'model_id' => 'model-A', 'model_quant' => 'Q5_K_M',
|
||||||
|
'gpu' => 'GPU1', 'gpu_count' => 1, 'engine' => 'llama.cpp', 'user_id' => $me->id, 'visibility' => 'public',
|
||||||
|
'ran_at' => '2026-09-03 12:00:00',
|
||||||
|
]);
|
||||||
|
SoftbenchResult::factory()->create(['benchmark_run_id' => $a->id, 'ok' => true, 'user_tps' => 50.0, 'total' => 100, 'level' => 'off']);
|
||||||
|
|
||||||
|
// Band B: GPU2 + model-B + UD-Q4_K_M + ollama-cloud
|
||||||
|
$b = BenchmarkRun::factory()->create([
|
||||||
|
'suite' => 'softbench', 'label' => 'band-b', 'model_id' => 'model-B', 'model_quant' => 'UD-Q4_K_M',
|
||||||
|
'gpu' => 'GPU2', 'gpu_count' => 1, 'engine' => 'ollama-cloud', 'user_id' => $me->id, 'visibility' => 'public',
|
||||||
|
'ran_at' => '2026-09-04 12:00:00',
|
||||||
|
]);
|
||||||
|
SoftbenchResult::factory()->create(['benchmark_run_id' => $b->id, 'ok' => true, 'user_tps' => 40.0, 'total' => 100, 'level' => 'off']);
|
||||||
|
|
||||||
|
Artisan::call('festbench:summary');
|
||||||
|
|
||||||
|
// GPU filter alone: OR within GPU → both bands.
|
||||||
|
Volt::test('summary.index', ['filters' => [
|
||||||
|
'gpu' => ['GPU1', 'GPU2'], 'model' => [], 'quant' => [], 'engine' => [], 'suite' => [],
|
||||||
|
]])
|
||||||
|
->assertSee('band-a')
|
||||||
|
->assertSee('band-b');
|
||||||
|
|
||||||
|
// GPU + model: AND across → only matching band.
|
||||||
|
Volt::test('summary.index', ['filters' => [
|
||||||
|
'gpu' => ['GPU1', 'GPU2'], 'model' => ['model-A'], 'quant' => [], 'engine' => [], 'suite' => [],
|
||||||
|
]])
|
||||||
|
->assertSee('band-a')
|
||||||
|
->assertDontSee('band-b');
|
||||||
|
|
||||||
|
// Engine filter: only llama.cpp band.
|
||||||
|
Volt::test('summary.index', ['filters' => [
|
||||||
|
'gpu' => [], 'model' => [], 'quant' => [], 'engine' => ['llama.cpp'], 'suite' => [],
|
||||||
|
]])
|
||||||
|
->assertSee('band-a')
|
||||||
|
->assertDontSee('band-b');
|
||||||
|
|
||||||
|
// Quant filter: only UD-Q4_K_M band.
|
||||||
|
Volt::test('summary.index', ['filters' => [
|
||||||
|
'gpu' => [], 'model' => [], 'quant' => ['UD-Q4_K_M'], 'engine' => [], 'suite' => [],
|
||||||
|
]])
|
||||||
|
->assertDontSee('band-a')
|
||||||
|
->assertSee('band-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-filter: multiple suites use OR for band inclusion', function () {
|
||||||
|
$me = User::factory()->create();
|
||||||
|
|
||||||
|
// Band with only softbench.
|
||||||
|
$soft = BenchmarkRun::factory()->create([
|
||||||
|
'suite' => 'softbench', 'label' => 'has-soft', 'model_id' => 'm', 'model_quant' => 'Q5_K_M',
|
||||||
|
'gpu' => 'GPU1', 'gpu_count' => 1, 'engine' => 'llama.cpp', 'user_id' => $me->id, 'visibility' => 'public',
|
||||||
|
'ran_at' => '2026-09-03 12:00:00',
|
||||||
|
]);
|
||||||
|
SoftbenchResult::factory()->create(['benchmark_run_id' => $soft->id, 'ok' => true, 'user_tps' => 50.0, 'total' => 100, 'level' => 'off']);
|
||||||
|
|
||||||
|
// Band with only infrabench.
|
||||||
|
$infra = BenchmarkRun::factory()->create([
|
||||||
|
'suite' => 'infrabench', 'label' => 'has-infra', 'model_id' => 'm', 'model_quant' => 'Q5_K_M',
|
||||||
|
'gpu' => 'GPU1', 'gpu_count' => 1, 'engine' => 'llama.cpp', 'user_id' => $me->id, 'visibility' => 'public',
|
||||||
|
'ran_at' => '2026-09-03 12:05:00',
|
||||||
|
]);
|
||||||
|
InfrabenchResult::factory()->create(['benchmark_run_id' => $infra->id, 'ok' => true, 'user_tps' => 30.0, 'total' => 100, 'level' => 'low']);
|
||||||
|
|
||||||
|
Artisan::call('festbench:summary');
|
||||||
|
|
||||||
|
// Both suites selected → both bands visible (OR within suite).
|
||||||
|
Volt::test('summary.index', ['filters' => [
|
||||||
|
'gpu' => [], 'model' => [], 'quant' => [], 'engine' => [], 'suite' => ['softbench', 'infrabench'],
|
||||||
|
]])
|
||||||
|
->assertSee('has-soft')
|
||||||
|
->assertSee('has-infra');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleFilter adds and removes a value from a filter array', function () {
|
||||||
|
$component = Volt::test('summary.index');
|
||||||
|
|
||||||
|
$component->call('toggleFilter', 'suite', 'infrabench')
|
||||||
|
->assertSet('filters.suite', ['infrabench']);
|
||||||
|
|
||||||
|
$component->call('toggleFilter', 'suite', 'infrabench')
|
||||||
|
->assertSet('filters.suite', []);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue