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:
parent
2e205ef792
commit
e406b52a2d
22 changed files with 929 additions and 7 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\BenchmarkRun;
|
use App\Models\BenchmarkRun;
|
||||||
|
use App\Models\Gpu;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Suites;
|
use App\Support\Suites;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
@ -13,10 +14,15 @@
|
||||||
#[AsCommand(name: 'festbench:import', description: 'Import a festbench benchmarks.db (SQLite) into the per-suite tables')]
|
#[AsCommand(name: 'festbench:import', description: 'Import a festbench benchmarks.db (SQLite) into the per-suite tables')]
|
||||||
class FestbenchImport extends Command
|
class FestbenchImport extends Command
|
||||||
{
|
{
|
||||||
|
/** @var array<string, bool> dedupe keys for warnOnce() */
|
||||||
|
private array $warned = [];
|
||||||
|
|
||||||
protected $signature = 'festbench:import
|
protected $signature = 'festbench:import
|
||||||
{path : Path to the festbench benchmarks.db}
|
{path : Path to the festbench benchmarks.db}
|
||||||
{--user= : Email of the user to attribute the imported runs to}
|
{--user= : Email of the user to attribute the imported runs to}
|
||||||
{--label= : Only import runs whose label matches exactly}
|
{--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}
|
{--private : Import runs as private instead of public}
|
||||||
{--fresh : Delete ALL existing benchmark data before importing}';
|
{--fresh : Delete ALL existing benchmark data before importing}';
|
||||||
|
|
||||||
|
|
@ -42,11 +48,20 @@ public function handle(): int
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$query = 'SELECT * FROM runs';
|
$query = 'SELECT * FROM runs';
|
||||||
|
$where = [];
|
||||||
$params = [];
|
$params = [];
|
||||||
if ($label = $this->option('label')) {
|
if ($label = $this->option('label')) {
|
||||||
$query .= ' WHERE label = ?';
|
$where[] = 'label = ?';
|
||||||
$params[] = $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 = $sqlite->prepare($query);
|
||||||
$runs->execute($params);
|
$runs->execute($params);
|
||||||
|
|
@ -117,6 +132,15 @@ private function confirmFresh(): bool
|
||||||
|
|
||||||
private function createRun(array $source, ?User $user): BenchmarkRun
|
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([
|
return BenchmarkRun::create([
|
||||||
'run_id' => $source['run_id'],
|
'run_id' => $source['run_id'],
|
||||||
'ran_at' => $source['ts'] ? Carbon::parse($source['ts']) : null,
|
'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,
|
'engine_build' => $source['engine_build'] ?? null,
|
||||||
'n_ctx' => $source['n_ctx'] ?? null,
|
'n_ctx' => $source['n_ctx'] ?? null,
|
||||||
'slots' => $source['slots'] ?? null,
|
'slots' => $source['slots'] ?? null,
|
||||||
'gpu' => $source['gpu'] ?? null,
|
'gpu' => $gpuName,
|
||||||
'gpu_count' => $source['gpu_count'] ?? null,
|
'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,
|
'host' => $source['host'] ?? null,
|
||||||
'client' => $source['client'] ?? null,
|
'client' => $source['client'] ?? null,
|
||||||
'endpoint' => $source['endpoint'] ?? null,
|
'endpoint' => $source['endpoint'] ?? null,
|
||||||
|
|
@ -255,6 +280,19 @@ private function suiteExtras(string $suite, array $row): array
|
||||||
return $extras;
|
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
|
private function resolveUser(): ?User
|
||||||
{
|
{
|
||||||
$email = $this->option('user');
|
$email = $this->option('user');
|
||||||
|
|
|
||||||
107
app/Console/Commands/Gpus.php
Normal file
107
app/Console/Commands/Gpus.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Console/Commands/Tokens.php
Normal file
63
app/Console/Commands/Tokens.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Http/Controllers/Api/CliDeviceController.php
Normal file
83
app/Http/Controllers/Api/CliDeviceController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Controllers/Api/GpuCatalogController.php
Normal file
27
app/Http/Controllers/Api/GpuCatalogController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Http/Controllers/Api/MeController.php
Normal file
18
app/Http/Controllers/Api/MeController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,14 +28,20 @@ class BenchmarkRun extends Model
|
||||||
'run_id', 'ran_at', 'suite', 'run_key',
|
'run_id', 'ran_at', 'suite', 'run_key',
|
||||||
'model_id', 'model_quant', 'model_quant_raw', 'model_path',
|
'model_id', 'model_quant', 'model_quant_raw', 'model_path',
|
||||||
'engine', 'engine_build', 'n_ctx', 'slots',
|
'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',
|
'label', 'config', 'image_digest', 'validators', 'suite_versions',
|
||||||
'harness', 'harness_ver', 'harness_mode', 'user_id',
|
'harness', 'harness_ver', 'harness_mode', 'user_id',
|
||||||
'visibility', 'notes',
|
'visibility', 'notes',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The only fields a user may change after submission. */
|
/** 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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
55
app/Models/DeviceCode.php
Normal file
55
app/Models/DeviceCode.php
Normal 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
36
app/Models/Gpu.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,11 +8,12 @@
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class User extends Authenticatable // implements MustVerifyEmail
|
class User extends Authenticatable // implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
|
@ -15,6 +18,14 @@
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
CachePublicGuestPages::class,
|
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) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/fortify": "^1.30",
|
"laravel/fortify": "^1.30",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"livewire/flux": "^2.0",
|
"livewire/flux": "^2.0",
|
||||||
"livewire/flux-pro": "^2.6",
|
"livewire/flux-pro": "^2.6",
|
||||||
|
|
|
||||||
65
composer.lock
generated
65
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "9369e02a3cd88fc23ac7651bc01e91c6",
|
"content-hash": "5041141100f49c43153c21fd46561058",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
|
|
@ -1629,6 +1629,69 @@
|
||||||
},
|
},
|
||||||
"time": "2026-08-20T12:55:36+00:00"
|
"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",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.16",
|
"version": "v2.0.16",
|
||||||
|
|
|
||||||
87
config/sanctum.php
Normal file
87
config/sanctum.php
Normal 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,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
37
database/migrations/2026_09_19_000002_create_gpu_catalog.php
Normal file
37
database/migrations/2026_09_19_000002_create_gpu_catalog.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
34
database/seeders/GpuSeeder.php
Normal file
34
database/seeders/GpuSeeder.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
resources/views/livewire/cli/authorize.blade.php
Normal file
78
resources/views/livewire/cli/authorize.blade.php
Normal 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>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\BenchmarkRun;
|
use App\Models\BenchmarkRun;
|
||||||
|
use App\Models\Gpu;
|
||||||
use Flux\Flux;
|
use Flux\Flux;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
|
@ -28,6 +29,54 @@ public function toggleVisibility(int $id): void
|
||||||
Flux::toast(variant: 'success', text: __('Visibility updated.'));
|
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
|
public function resetFilters(): void
|
||||||
{
|
{
|
||||||
$this->filters = ['search' => null];
|
$this->filters = ['search' => null];
|
||||||
|
|
@ -89,6 +138,7 @@ class="w-72"
|
||||||
<flux:table.column>{{ __('Run') }}</flux:table.column>
|
<flux:table.column>{{ __('Run') }}</flux:table.column>
|
||||||
<flux:table.column>{{ __('Model') }}</flux:table.column>
|
<flux:table.column>{{ __('Model') }}</flux:table.column>
|
||||||
<flux:table.column>{{ __('Suite') }}</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">{{ __('Tasks') }}</flux:table.column>
|
||||||
<flux:table.column class="text-right">{{ __('Passed') }}</flux:table.column>
|
<flux:table.column class="text-right">{{ __('Passed') }}</flux:table.column>
|
||||||
<flux:table.column>{{ __('Visibility') }}</flux:table.column>
|
<flux:table.column>{{ __('Visibility') }}</flux:table.column>
|
||||||
|
|
@ -104,6 +154,17 @@ class="w-72"
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
<flux:table.cell>{{ $run->model_id }}</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><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->results_count }}</flux:table.cell>
|
||||||
<flux:table.cell class="text-right font-mono text-sm">{{ $run->passed_count }}</flux:table.cell>
|
<flux:table.cell class="text-right font-mono text-sm">{{ $run->passed_count }}</flux:table.cell>
|
||||||
<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.text>{{ __('Runs you import or submit will appear here.') }}</flux:callout.text>
|
||||||
</flux:callout>
|
</flux:callout>
|
||||||
@endif
|
@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>
|
</div>
|
||||||
18
routes/api.php
Normal file
18
routes/api.php
Normal 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']);
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,11 @@
|
||||||
|
|
||||||
Route::livewire('runs/{run}', 'runs.show')->name('runs.show');
|
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::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::livewire('my-runs', 'my-runs.index')->name('my-runs');
|
Route::livewire('my-runs', 'my-runs.index')->name('my-runs');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue