diff --git a/app/Console/Commands/FestbenchImport.php b/app/Console/Commands/FestbenchImport.php index 6b5fc35..6ccc87a 100644 --- a/app/Console/Commands/FestbenchImport.php +++ b/app/Console/Commands/FestbenchImport.php @@ -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 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'); diff --git a/app/Console/Commands/Gpus.php b/app/Console/Commands/Gpus.php new file mode 100644 index 0000000..ca9cf75 --- /dev/null +++ b/app/Console/Commands/Gpus.php @@ -0,0 +1,107 @@ +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', + }; + } +} diff --git a/app/Console/Commands/Tokens.php b/app/Console/Commands/Tokens.php new file mode 100644 index 0000000..c4d2285 --- /dev/null +++ b/app/Console/Commands/Tokens.php @@ -0,0 +1,63 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/CliDeviceController.php b/app/Http/Controllers/Api/CliDeviceController.php new file mode 100644 index 0000000..526341a --- /dev/null +++ b/app/Http/Controllers/Api/CliDeviceController.php @@ -0,0 +1,83 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Api/GpuCatalogController.php b/app/Http/Controllers/Api/GpuCatalogController.php new file mode 100644 index 0000000..5b249c3 --- /dev/null +++ b/app/Http/Controllers/Api/GpuCatalogController.php @@ -0,0 +1,27 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/Api/MeController.php b/app/Http/Controllers/Api/MeController.php new file mode 100644 index 0000000..0cc2951 --- /dev/null +++ b/app/Http/Controllers/Api/MeController.php @@ -0,0 +1,18 @@ +json([ + 'id' => auth()->id(), + 'name' => auth()->user()->name, + 'email' => auth()->user()->email, + ]); + } +} diff --git a/app/Models/BenchmarkRun.php b/app/Models/BenchmarkRun.php index b03eec1..0671069 100644 --- a/app/Models/BenchmarkRun.php +++ b/app/Models/BenchmarkRun.php @@ -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 { diff --git a/app/Models/DeviceCode.php b/app/Models/DeviceCode.php new file mode 100644 index 0000000..395fb14 --- /dev/null +++ b/app/Models/DeviceCode.php @@ -0,0 +1,55 @@ + 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', + ]; + } +} diff --git a/app/Models/Gpu.php b/app/Models/Gpu.php new file mode 100644 index 0000000..b935ee9 --- /dev/null +++ b/app/Models/Gpu.php @@ -0,0 +1,36 @@ + */ + 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); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index ec6c464..465b589 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/bootstrap/app.php b/bootstrap/app.php index 62059df..1e1329f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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) { // diff --git a/composer.json b/composer.json index 3e85448..9c753ee 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index c9e374c..1a3ae70 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + 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, + ], + +]; diff --git a/database/migrations/2026_09_19_000001_create_device_codes.php b/database/migrations/2026_09_19_000001_create_device_codes.php new file mode 100644 index 0000000..9d6ae96 --- /dev/null +++ b/database/migrations/2026_09_19_000001_create_device_codes.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_19_000002_create_gpu_catalog.php b/database/migrations/2026_09_19_000002_create_gpu_catalog.php new file mode 100644 index 0000000..aa98a24 --- /dev/null +++ b/database/migrations/2026_09_19_000002_create_gpu_catalog.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_19_165146_create_personal_access_tokens_table.php b/database/migrations/2026_09_19_165146_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_09_19_165146_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/seeders/GpuSeeder.php b/database/seeders/GpuSeeder.php new file mode 100644 index 0000000..b0e80a9 --- /dev/null +++ b/database/seeders/GpuSeeder.php @@ -0,0 +1,34 @@ + '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]); + } + } +} diff --git a/resources/views/livewire/cli/authorize.blade.php b/resources/views/livewire/cli/authorize.blade.php new file mode 100644 index 0000000..a7bce87 --- /dev/null +++ b/resources/views/livewire/cli/authorize.blade.php @@ -0,0 +1,78 @@ +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.')); + } +}; +?> + +
+ +
+ + + @if ($granted) + {{ __('CLI connected') }} + {{ __('The festbench CLI on your machine now has access. Return to your terminal to continue.') }} + @elseif ($failed) + {{ __('Invalid or expired code') }} + {{ __('This code does not exist or has expired. Re-run festbench login to get a fresh one.') }} + {{ __('Back') }} + @else + {{ __('Confirm CLI access') }} + {{ __('A festbench CLI is requesting access to your account. Make sure the code below matches the one shown in your terminal.') }} +
+ {{ $code }} +
+ {{ __('Grant access') }} + @endif +
+
+
\ No newline at end of file diff --git a/resources/views/livewire/my-runs/index.blade.php b/resources/views/livewire/my-runs/index.blade.php index 8baf926..9aa73c5 100644 --- a/resources/views/livewire/my-runs/index.blade.php +++ b/resources/views/livewire/my-runs/index.blade.php @@ -1,6 +1,7 @@ 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" {{ __('Run') }} {{ __('Model') }} {{ __('Suite') }} + {{ __('GPU') }} {{ __('Tasks') }} {{ __('Passed') }} {{ __('Visibility') }} @@ -104,6 +154,17 @@ class="w-72" {{ $run->model_id }} {{ $run->suite }} + + @if ($run->gpu_id !== null) + {{ $run->gpu }} + @elseif ($run->gpu !== null && ! in_array(strtolower($run->gpu), ['unknown', 'hosted', 'cloud'])) + + {{ __('Unconfirmed') }}: {{ $run->gpu }} + + @else + {{ $run->gpu ?? '—' }} + @endif + {{ $run->results_count }} {{ $run->passed_count }} @@ -131,4 +192,31 @@ class="w-72" {{ __('Runs you import or submit will appear here.') }} @endif + + +
+
+ {{ __('Confirm GPU') }} + {{ __('Pick the card this run executed on. Confirming regroups the run into the matching session.') }} +
+ + + +
+ @foreach ($this->gpuChoices as $gpuId => $gpuName) + + {{ $gpuName }} + + @endforeach + @if (count($this->gpuChoices) === 0) + {{ __('No catalog matches — ask an admin to add this card.') }} + @endif +
+
+
\ No newline at end of file diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..591cd1d --- /dev/null +++ b/routes/api.php @@ -0,0 +1,18 @@ +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']); +}); diff --git a/routes/web.php b/routes/web.php index 0c6bb19..26b8f1f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');