CLI device login flow (Sanctum + device_codes, authorize screen, /api/me, /api/gpus) + canonical GPU catalog (gpus table, seeder, import normalizer, my-runs confirm picker, festbench:gpus + festbench:tokens)

This commit is contained in:
PeterChrz 2026-09-19 12:56:40 -04:00
parent 2e205ef792
commit e406b52a2d
Signed by untrusted user who does not match committer: pch
GPG key ID: 8F0826ECF7302C63
22 changed files with 929 additions and 7 deletions

View file

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Models\BenchmarkRun;
use App\Models\Gpu;
use App\Models\User;
use App\Support\Suites;
use Illuminate\Console\Command;
@ -13,10 +14,15 @@
#[AsCommand(name: 'festbench:import', description: 'Import a festbench benchmarks.db (SQLite) into the per-suite tables')]
class FestbenchImport extends Command
{
/** @var array<string, bool> dedupe keys for warnOnce() */
private array $warned = [];
protected $signature = 'festbench:import
{path : Path to the festbench benchmarks.db}
{--user= : Email of the user to attribute the imported runs to}
{--label= : Only import runs whose label matches exactly}
{--run-id= : Comma-separated list of run_ids to import}
{--gpu= : Override the GPU name on imported runs (backfill or correction)}
{--private : Import runs as private instead of public}
{--fresh : Delete ALL existing benchmark data before importing}';
@ -42,11 +48,20 @@ public function handle(): int
]);
$query = 'SELECT * FROM runs';
$where = [];
$params = [];
if ($label = $this->option('label')) {
$query .= ' WHERE label = ?';
$where[] = 'label = ?';
$params[] = $label;
}
if ($runIds = $this->option('run-id')) {
$ids = array_map('trim', explode(',', (string) $runIds));
$where[] = 'run_id IN ('.implode(',', array_fill(0, count($ids), '?')).')';
$params = [...$params, ...$ids];
}
if ($where !== []) {
$query .= ' WHERE '.implode(' AND ', $where);
}
$runs = $sqlite->prepare($query);
$runs->execute($params);
@ -117,6 +132,15 @@ private function confirmFresh(): bool
private function createRun(array $source, ?User $user): BenchmarkRun
{
// --gpu overrides the recorded hardware (backfill/correction); the
// source row keeps its provenance inside payload/run_key untouched.
$gpuName = $this->option('gpu') ?? ($source['gpu'] ?? null);
$gpu = Gpu::match($gpuName);
if ($gpuName !== null && $gpu === null && ! in_array(strtolower((string) $gpuName), ['unknown', 'hosted', 'cloud'], true)) {
$this->warnOnce("GPU '{$gpuName}' is not in the catalog (run {$source['run_id']}) — it can be confirmed on the website later.");
}
return BenchmarkRun::create([
'run_id' => $source['run_id'],
'ran_at' => $source['ts'] ? Carbon::parse($source['ts']) : null,
@ -130,9 +154,10 @@ private function createRun(array $source, ?User $user): BenchmarkRun
'engine_build' => $source['engine_build'] ?? null,
'n_ctx' => $source['n_ctx'] ?? null,
'slots' => $source['slots'] ?? null,
'gpu' => $source['gpu'] ?? null,
'gpu' => $gpuName,
'gpu_count' => $source['gpu_count'] ?? null,
'gpu_source' => $source['gpu_source'] ?? null,
'gpu_id' => $gpu?->id,
'gpu_source' => $this->option('gpu') ? ($source['gpu_source'] ?? 'backfill-manual') : ($source['gpu_source'] ?? null),
'host' => $source['host'] ?? null,
'client' => $source['client'] ?? null,
'endpoint' => $source['endpoint'] ?? null,
@ -255,6 +280,19 @@ private function suiteExtras(string $suite, array $row): array
return $extras;
}
/** Warn for a repeated condition only once per import run. */
private function warnOnce(string $message): void
{
$key = md5($message);
if (isset($this->warned[$key])) {
return;
}
$this->warned[$key] = true;
$this->warn($message);
}
private function resolveUser(): ?User
{
$email = $this->option('user');

View file

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands;
use App\Models\Gpu;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'festbench:gpus', description: 'Manage the canonical GPU catalog')]
class Gpus extends Command
{
protected $signature = 'festbench:gpus
{action : list|add|deactivate}
{--name= : GPU name (add/deactivate)}
{--vendor= : amd|nvidia|intel|other (add)}';
public function handle(): int
{
return match ($this->argument('action')) {
'list' => $this->listGpus(),
'add' => $this->addGpu(),
'deactivate' => $this->deactivateGpu(),
default => (function () {
$this->error('Unknown action. Use list, add or deactivate.');
return self::FAILURE;
})(),
};
}
private function listGpus(): int
{
$this->table(
['id', 'name', 'vendor', 'active'],
Gpu::orderBy('vendor')->orderBy('name')->get(['id', 'name', 'vendor', 'active'])
->map(fn ($g) => [$g->id, $g->name, $g->vendor, $g->active ? 'yes' : 'no'])->all()
);
return self::SUCCESS;
}
private function addGpu(): int
{
$name = $this->option('name');
if ($name === null) {
$this->error('--name is required.');
return self::FAILURE;
}
$vendor = $this->option('vendor') ?? $this->guessVendor($name);
if (! in_array($vendor, ['amd', 'nvidia', 'intel', 'other'], true)) {
$this->error('--vendor must be amd, nvidia, intel or other.');
return self::FAILURE;
}
$gpu = Gpu::updateOrCreate(
['name' => trim($name)],
['vendor' => $vendor, 'active' => true]
);
$this->info($gpu->wasRecentlyCreated
? "GPU {$gpu->name} ({$gpu->vendor}) added."
: "GPU {$gpu->name} ({$gpu->vendor}) updated.");
return self::SUCCESS;
}
private function deactivateGpu(): int
{
$name = $this->option('name');
if ($name === null) {
$this->error('--name is required.');
return self::FAILURE;
}
$gpu = Gpu::match($name);
if ($gpu === null) {
$this->error("GPU {$name} not found.");
return self::FAILURE;
}
$gpu->update(['active' => false]);
$this->info("GPU {$gpu->name} deactivated (historical runs keep their links).");
return self::SUCCESS;
}
private function guessVendor(string $name): string
{
$lower = strtolower($name);
return match (true) {
str_contains($lower, 'radeon') || str_contains($lower, 'amd') || str_contains($lower, 'instinct') => 'amd',
str_contains($lower, 'nvidia') || str_contains($lower, 'geforce') || str_contains($lower, 'rtx') || str_contains($lower, 'gtx') => 'nvidia',
str_contains($lower, 'intel') || str_contains($lower, 'arc') => 'intel',
default => 'other',
};
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'festbench:tokens', description: 'List or revoke CLI personal access tokens')]
class Tokens extends Command
{
protected $signature = 'festbench:tokens
{--revoke= : Token ID to revoke}
{--user= : Filter by user email}';
public function handle(): int
{
if ($id = $this->option('revoke')) {
$token = PersonalAccessToken::find($id);
if ($token === null) {
$this->error("Token {$id} not found.");
return self::FAILURE;
}
$token->delete();
$this->info("Token {$id} revoked.");
return self::SUCCESS;
}
$query = PersonalAccessToken::query()
->orderByDesc('created_at');
if ($email = $this->option('user')) {
$user = User::where('email', $email)->first();
$query->where('tokenable_id', $user?->id ?? 0);
}
$tokens = $query->get(['id', 'name', 'tokenable_id', 'created_at', 'expires_at']);
if ($tokens->isEmpty()) {
$this->info('No tokens.');
return self::SUCCESS;
}
$this->table(
['id', 'user', 'name', 'created', 'expires'],
$tokens->map(fn ($t) => [
$t->id,
User::find($t->tokenable_id)?->email ?? $t->tokenable_id,
$t->name,
$t->created_at?->format('Y-m-d'),
$t->expires_at?->format('Y-m-d') ?? 'never',
])->all()
);
return self::SUCCESS;
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\DeviceCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CliDeviceController extends Controller
{
/**
* Step 1: the CLI asks for a device code. Public but rate-limited —
* this is the front door for the Geekbench-style login flow.
*/
public function request(Request $request): JsonResponse
{
$request->validate(['api' => ['nullable', 'string', 'max:32']]);
$code = DeviceCode::generateCode();
$pollToken = DeviceCode::generatePollToken();
DeviceCode::create([
'code' => $code,
'poll_token_hash' => hash('sha256', $pollToken),
'expires_at' => now()->addMinutes(10),
]);
return response()->json([
'code' => $code,
'poll_token' => $pollToken,
'verify_url' => url('/cli/authorize/'.$code),
'expires_in' => 600,
], 201);
}
/**
* Step 3: the CLI polls until the user confirms in the browser.
* Returns pending / expired / granted{token}.
*/
public function poll(Request $request): JsonResponse
{
$validated = $request->validate([
'code' => ['required', 'string'],
'poll_token' => ['required', 'string'],
]);
$device = DeviceCode::where('code', $validated['code'])->first();
if ($device === null || ! hash_equals($device->poll_token_hash, hash('sha256', $validated['poll_token']))) {
// Same answer for unknown code and wrong token: no oracle.
return response()->json(['status' => 'unknown'], 404);
}
if ($device->expires_at->isPast()) {
return response()->json(['status' => 'expired']);
}
if ($device->consumed_at !== null) {
// The token can be picked up exactly once by poll; a later poll
// after consumption is a replay and gets a bare granted status.
return response()->json(['status' => 'granted']);
}
if ($device->user_id === null) {
return response()->json(['status' => 'pending']);
}
$token = $device->personalAccessToken;
if ($token === null || $token->expires_at !== null && $token->expires_at->isPast()) {
return response()->json(['status' => 'expired']);
}
$device->forceFill(['consumed_at' => now()])->save();
return response()->json([
'status' => 'granted',
'token' => $token->token,
'abilities' => $token->abilities,
]);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Gpu;
use Illuminate\Http\JsonResponse;
class GpuCatalogController extends Controller
{
/**
* The canonical GPU list, authenticated for the CLI. One GPU, one
* identity — this is the list the CLI's picker and normalizer trust.
*/
public function index(): JsonResponse
{
return response()->json([
'gpus' => Gpu::query()
->where('active', true)
->orderBy('vendor')
->orderBy('name')
->get(['name', 'vendor'])
->map(fn (Gpu $gpu) => ['name' => $gpu->name, 'vendor' => $gpu->vendor])
->all(),
]);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class MeController extends Controller
{
public function __invoke(): JsonResponse
{
return response()->json([
'id' => auth()->id(),
'name' => auth()->user()->name,
'email' => auth()->user()->email,
]);
}
}

View file

@ -28,14 +28,20 @@ class BenchmarkRun extends Model
'run_id', 'ran_at', 'suite', 'run_key',
'model_id', 'model_quant', 'model_quant_raw', 'model_path',
'engine', 'engine_build', 'n_ctx', 'slots',
'gpu', 'gpu_count', 'gpu_source', 'host', 'client', 'endpoint',
'gpu', 'gpu_count', 'gpu_id', 'gpu_source', 'host', 'client', 'endpoint',
'label', 'config', 'image_digest', 'validators', 'suite_versions',
'harness', 'harness_ver', 'harness_mode', 'user_id',
'visibility', 'notes',
];
/** The only fields a user may change after submission. */
public const OVERLAY_FIELDS = ['visibility', 'notes'];
/**
* The only fields a user may change after submission. gpu/gpu_id are
* included deliberately: confirming a run's GPU against the canonical
* catalog is owner behavior (it links identity, it does not alter
* benchmark metrics).
*/
public const OVERLAY_FIELDS = ['visibility', 'notes', 'gpu', 'gpu_id'];
protected function casts(): array
{

55
app/Models/DeviceCode.php Normal file
View file

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
use Laravel\Sanctum\PersonalAccessToken;
class DeviceCode extends Model
{
protected $fillable = [
'code', 'poll_token_hash', 'user_id', 'personal_access_token_id',
'expires_at', 'consumed_at',
];
/**
* Human-typable code: 4 + 4 upper-case letters/digits without ambiguous
* characters, joined by a dash (e.g. WDJB-MJDH).
*/
public static function generateCode(): string
{
$alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
$segment = fn () => substr(str_shuffle(str_repeat($alphabet, 8)), 0, 4);
return $segment().'-'.$segment();
}
/**
* Random secret the CLI keeps; only its hash is stored.
*/
public static function generatePollToken(): string
{
return Str::random(48);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function personalAccessToken(): BelongsTo
{
return $this->belongsTo(PersonalAccessToken::class, 'personal_access_token_id');
}
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'consumed_at' => 'datetime',
];
}
}

36
app/Models/Gpu.php Normal file
View file

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Database\Factories\GpuFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Gpu extends Model
{
/** @use HasFactory<GpuFactory> */
use HasFactory;
protected $fillable = ['name', 'vendor', 'active'];
/**
* Case-insensitive lookup by canonical name — the single matching rule
* used by imports and future submissions to link gpu_id.
*/
public static function match(?string $name): ?self
{
if ($name === null || trim($name) === '' || in_array(strtolower(trim($name)), ['unknown', 'hosted', 'cloud'], true)) {
return null;
}
return static::query()
->whereRaw('lower(name) = ?', [strtolower(trim($name))])
->first();
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
}

View file

@ -8,11 +8,12 @@
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable // implements MustVerifyEmail
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.

View file

@ -4,10 +4,13 @@
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
@ -15,6 +18,14 @@
$middleware->web(append: [
CachePublicGuestPages::class,
]);
// Sanctum v4 registers its middleware as a service provider alias;
// register the token-ability check explicitly so `ability:` routes
// resolve.
$middleware->alias([
'ability' => CheckAbilities::class,
'abilities' => CheckForAnyAbility::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//

View file

@ -12,6 +12,7 @@
"php": "^8.2",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0",
"livewire/flux-pro": "^2.6",

65
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9369e02a3cd88fc23ac7651bc01e91c6",
"content-hash": "5041141100f49c43153c21fd46561058",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -1629,6 +1629,69 @@
},
"time": "2026-08-20T12:55:36+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.3.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "fee27a573d1a013af3721d86153a65e0b11927e6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6",
"reference": "fee27a573d1a013af3721d86153a65e0b11927e6",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/database": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/console": "^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2026-06-23T18:26:55+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.16",

87
config/sanctum.php Normal file
View file

@ -0,0 +1,87 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Device authorization flow (Geekbench-style): the CLI generates a
* human-typable code + a poll token, the user confirms the code in a
* browser on the website, and the CLI's next poll receives a Sanctum
* personal access token. Rows are short-lived and purged opportunistically.
*/
public function up(): void
{
Schema::create('device_codes', function (Blueprint $table) {
$table->id();
$table->string('code', 12)->unique();
$table->string('poll_token_hash', 128);
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('personal_access_token_id')->nullable();
$table->timestamp('expires_at')->index();
$table->timestamp('consumed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('device_codes');
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Canonical GPU catalog. One GPU, one identity: users never mint names.
* Runs link to gpus via gpu_id; the raw gpu string stays for provenance
* and unconfirmed runs (gpu_id null) are flagged in the UI until the
* owner confirms them against the catalog.
*/
public function up(): void
{
Schema::create('gpus', function (Blueprint $table) {
$table->id();
$table->string('name', 128)->unique()->comment('canonical name, e.g. AMD Radeon AI PRO R9700');
$table->string('vendor', 32)->comment('amd|nvidia|intel|other');
$table->boolean('active')->default(true);
$table->timestamps();
});
Schema::table('benchmark_runs', function (Blueprint $table) {
$table->foreignId('gpu_id')->nullable()->after('gpu_count')->constrained()->nullOnDelete();
});
}
public function down(): void
{
Schema::table('benchmark_runs', function (Blueprint $table) {
$table->dropConstrainedForeignId('gpu_id');
});
Schema::dropIfExists('gpus');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View file

@ -0,0 +1,34 @@
<?php
namespace Database\Seeders;
use App\Models\Gpu;
use Illuminate\Database\Seeder;
class GpuSeeder extends Seeder
{
/**
* Canonical GPU catalog — deliberately small and curated. New cards are
* added via `festbench:gpus` when a real user actually has one; one GPU,
* one identity.
*/
public function run(): void
{
$gpus = [
['name' => 'AMD Radeon AI PRO R9700', 'vendor' => 'amd'],
['name' => 'AMD Radeon PRO W7900', 'vendor' => 'amd'],
['name' => 'AMD Radeon RX 7900 XTX', 'vendor' => 'amd'],
['name' => 'AMD Instinct MI300X', 'vendor' => 'amd'],
['name' => 'NVIDIA RTX 4090', 'vendor' => 'nvidia'],
['name' => 'NVIDIA RTX 5090', 'vendor' => 'nvidia'],
['name' => 'NVIDIA RTX 6000 Ada', 'vendor' => 'nvidia'],
['name' => 'NVIDIA H100', 'vendor' => 'nvidia'],
['name' => 'NVIDIA A100', 'vendor' => 'nvidia'],
['name' => 'Intel Arc A770', 'vendor' => 'intel'],
];
foreach ($gpus as $gpu) {
Gpu::updateOrCreate(['name' => $gpu['name']], ['vendor' => $gpu['vendor'], 'active' => true]);
}
}
}

View file

@ -0,0 +1,78 @@
<?php
use App\Models\DeviceCode;
use Flux\Flux;
use Livewire\Attributes\Layout;
use Livewire\Component;
new #[Layout('components.layouts.app')] class extends Component {
public string $code = '';
public ?DeviceCode $device = null;
public bool $granted = false;
public bool $failed = false;
public function mount(string $code): void
{
$this->code = strtoupper(trim($code));
$this->device = DeviceCode::where('code', $this->code)->first();
// Unknown or expired codes never render a grant button.
$this->failed = $this->device === null || $this->device->expires_at->isPast();
}
public function grant(): void
{
if ($this->failed || $this->device === null) {
return;
}
$user = auth()->user();
if ($this->device->user_id !== null) {
$this->failed = true;
return;
}
// Mint a CLI token with abilities for the catalog (live now) and
// submissions (endpoint to come; ability is forward-compatible).
$token = $user->createToken('festbench-cli', ['gpus:read', 'results:submit'], now()->addYear());
$this->device->forceFill([
'user_id' => $user->id,
'personal_access_token_id' => $token->accessToken->id,
])->save();
$this->granted = true;
Flux::toast(variant: 'success', text: __('CLI connected. Return to your terminal.'));
}
};
?>
<div class="mx-auto max-w-md">
<flux:card>
<div class="flex flex-col items-center gap-4 text-center">
<flux:icon name="terminal" class="size-8 text-zinc-400" />
@if ($granted)
<flux:heading size="lg">{{ __('CLI connected') }}</flux:heading>
<flux:text>{{ __('The festbench CLI on your machine now has access. Return to your terminal to continue.') }}</flux:text>
@elseif ($failed)
<flux:heading size="lg">{{ __('Invalid or expired code') }}</flux:heading>
<flux:text>{{ __('This code does not exist or has expired. Re-run festbench login to get a fresh one.') }}</flux:text>
<flux:button variant="subtle" href="{{ route('home') }}" wire:navigate>{{ __('Back') }}</flux:button>
@else
<flux:heading size="lg">{{ __('Confirm CLI access') }}</flux:heading>
<flux:text>{{ __('A festbench CLI is requesting access to your account. Make sure the code below matches the one shown in your terminal.') }}</flux:text>
<div class="rounded-lg border border-zinc-200 bg-zinc-50 px-8 py-4 font-mono text-2xl font-bold tracking-widest dark:border-zinc-700 dark:bg-zinc-800">
{{ $code }}
</div>
<flux:button variant="primary" wire:click="grant" class="w-full">{{ __('Grant access') }}</flux:button>
@endif
</div>
</flux:card>
</div>

View file

@ -1,6 +1,7 @@
<?php
use App\Models\BenchmarkRun;
use App\Models\Gpu;
use Flux\Flux;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Computed;
@ -28,6 +29,54 @@ public function toggleVisibility(int $id): void
Flux::toast(variant: 'success', text: __('Visibility updated.'));
}
// --- GPU confirmation -------------------------------------------------
// Runs whose raw gpu string has no catalog match carry gpu_id = null.
// The owner confirms them here; confirming links gpu_id and re-runs the
// summary so the run retro-merges into the correct session band.
public ?int $confirmingRunId = null;
public string $gpuSearch = '';
#[Computed]
public function gpuChoices(): array
{
if ($this->gpuSearch === '') {
return Gpu::active()->orderBy('vendor')->orderBy('name')->limit(10)->pluck('name', 'id')->all();
}
return Gpu::active()
->where('name', 'like', "%{$this->gpuSearch}%")
->orderBy('name')
->limit(10)
->pluck('name', 'id')
->all();
}
public function startGpuConfirm(int $id): void
{
$this->confirmingRunId = $id;
$this->gpuSearch = '';
$this->modal('gpu-confirm-modal')->show();
}
public function confirmGpu(int $gpuId): void
{
$run = BenchmarkRun::where('user_id', auth()->id())->findOrFail($this->confirmingRunId);
$gpu = Gpu::active()->findOrFail($gpuId);
$run->fill(['gpu' => $gpu->name, 'gpu_id' => $gpu->id])->save();
// Historical regroup: the run moves into the correct session band.
$this->call('festbench:summary');
$this->call('festbench:snapshot');
$this->modal('gpu-confirm-modal')->close();
$this->confirmingRunId = null;
Flux::toast(variant: 'success', text: __('GPU confirmed — run regrouped.'));
}
public function resetFilters(): void
{
$this->filters = ['search' => null];
@ -89,6 +138,7 @@ class="w-72"
<flux:table.column>{{ __('Run') }}</flux:table.column>
<flux:table.column>{{ __('Model') }}</flux:table.column>
<flux:table.column>{{ __('Suite') }}</flux:table.column>
<flux:table.column>{{ __('GPU') }}</flux:table.column>
<flux:table.column class="text-right">{{ __('Tasks') }}</flux:table.column>
<flux:table.column class="text-right">{{ __('Passed') }}</flux:table.column>
<flux:table.column>{{ __('Visibility') }}</flux:table.column>
@ -104,6 +154,17 @@ class="w-72"
</flux:table.cell>
<flux:table.cell>{{ $run->model_id }}</flux:table.cell>
<flux:table.cell><flux:badge size="xs">{{ $run->suite }}</flux:badge></flux:table.cell>
<flux:table.cell>
@if ($run->gpu_id !== null)
<span class="text-xs">{{ $run->gpu }}</span>
@elseif ($run->gpu !== null && ! in_array(strtolower($run->gpu), ['unknown', 'hosted', 'cloud']))
<flux:badge size="xs" color="amber" class="cursor-pointer" wire:click="startGpuConfirm({{ $run->id }})">
{{ __('Unconfirmed') }}: {{ $run->gpu }}
</flux:badge>
@else
<span class="text-xs text-zinc-400">{{ $run->gpu ?? '—' }}</span>
@endif
</flux:table.cell>
<flux:table.cell class="text-right font-mono text-sm">{{ $run->results_count }}</flux:table.cell>
<flux:table.cell class="text-right font-mono text-sm">{{ $run->passed_count }}</flux:table.cell>
<flux:table.cell>
@ -131,4 +192,31 @@ class="w-72"
<flux:callout.text>{{ __('Runs you import or submit will appear here.') }}</flux:callout.text>
</flux:callout>
@endif
<flux:modal name="gpu-confirm-modal" class="w-96">
<div class="flex flex-col gap-4">
<div>
<flux:heading size="lg">{{ __('Confirm GPU') }}</flux:heading>
<flux:subheading>{{ __('Pick the card this run executed on. Confirming regroups the run into the matching session.') }}</flux:subheading>
</div>
<flux:input
wire:model.live.debounce.200ms="gpuSearch"
:label="__('Search GPUs')"
:placeholder="__('Type to search the catalog…')"
clearable
/>
<div class="flex max-h-64 flex-col gap-1 overflow-y-auto">
@foreach ($this->gpuChoices as $gpuId => $gpuName)
<flux:button variant="subtle" wire:click="confirmGpu({{ $gpuId }})" class="justify-start">
{{ $gpuName }}
</flux:button>
@endforeach
@if (count($this->gpuChoices) === 0)
<flux:text size="sm" class="text-zinc-400">{{ __('No catalog matches — ask an admin to add this card.') }}</flux:text>
@endif
</div>
</div>
</flux:modal>
</div>

18
routes/api.php Normal file
View file

@ -0,0 +1,18 @@
<?php
use App\Http\Controllers\Api\CliDeviceController;
use App\Http\Controllers\Api\GpuCatalogController;
use App\Http\Controllers\Api\MeController;
use Illuminate\Support\Facades\Route;
Route::get('/me', MeController::class)->middleware('auth:sanctum');
Route::middleware('auth:sanctum')->group(function () {
Route::get('/gpus', [GpuCatalogController::class, 'index'])->middleware('ability:gpus:read');
});
Route::middleware('throttle:10,1')->group(function () {
// Device authorization front door — public but rate-limited.
Route::post('/cli/device', [CliDeviceController::class, 'request']);
Route::post('/cli/poll', [CliDeviceController::class, 'poll']);
});

View file

@ -8,6 +8,11 @@
Route::livewire('runs/{run}', 'runs.show')->name('runs.show');
// Device authorization for the festbench CLI (Geekbench-style login flow).
Route::livewire('cli/authorize/{code}', 'cli.authorize')
->middleware(['auth', 'verified'])
->name('cli.authorize');
Route::middleware(['auth', 'verified'])->group(function () {
Route::livewire('my-runs', 'my-runs.index')->name('my-runs');