Add BamBuddy AMS, queue, history and stats to the print farm page
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
f7e3c8fe16
commit
b11538944f
@@ -36,6 +36,9 @@ class RefreshIntegrationCache extends Command
|
|||||||
|
|
||||||
if ($bambuddy->configured()) {
|
if ($bambuddy->configured()) {
|
||||||
$this->refresh('bambuddy printers', fn () => $bambuddy->printers());
|
$this->refresh('bambuddy printers', fn () => $bambuddy->printers());
|
||||||
|
$this->refresh('bambuddy queue', fn () => $bambuddy->queue());
|
||||||
|
$this->refresh('bambuddy recent prints', fn () => $bambuddy->recentPrints());
|
||||||
|
$this->refresh('bambuddy stats', fn () => $bambuddy->stats());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dryer->configured()) {
|
if ($dryer->configured()) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Services\Bambuddy;
|
use App\Services\Bambuddy;
|
||||||
|
use Closure;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -20,6 +21,27 @@ class BambuddyController extends Controller
|
|||||||
$integrationError = 'BamBuddy is unavailable. Check its API key and connection.';
|
$integrationError = 'BamBuddy is unavailable. Check its API key and connection.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('printers.index', compact('printers', 'integrationError') + ['bambuddyUrl' => $bambuddy->url()]);
|
$queue = $printers === [] ? [] : $this->optional(fn (): array => $bambuddy->queue());
|
||||||
|
$recentPrints = $printers === [] ? [] : $this->optional(fn (): array => $bambuddy->recentPrints());
|
||||||
|
$stats = $printers === [] ? [] : $this->optional(fn (): array => $bambuddy->stats());
|
||||||
|
|
||||||
|
return view('printers.index', compact('printers', 'integrationError', 'queue', 'recentPrints', 'stats') + ['bambuddyUrl' => $bambuddy->url()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load secondary page data without letting a failure hide the printers.
|
||||||
|
*
|
||||||
|
* @param Closure(): array<mixed> $load
|
||||||
|
* @return array<mixed>
|
||||||
|
*/
|
||||||
|
private function optional(Closure $load): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $load();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,39 @@ class Bambuddy
|
|||||||
return $this->cache->remember('bambuddy.printers.'.md5($this->url()), 45, fn (): array => $this->fetchPrinters());
|
return $this->cache->remember('bambuddy.printers.'.md5($this->url()), 45, fn (): array => $this->fetchPrinters());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array<string, mixed>> Jobs still waiting or running in the BamBuddy queue. */
|
||||||
|
public function queue(): array
|
||||||
|
{
|
||||||
|
return $this->cache->remember('bambuddy.queue.'.md5($this->url()), 45, fn (): array => collect($this->get('queue/'))
|
||||||
|
->filter(fn ($item): bool => is_array($item) && empty($item['completed_at']) && in_array($item['status'] ?? 'pending', ['pending', 'printing', 'queued', 'waiting'], true))
|
||||||
|
->sortBy('position')->values()->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array<string, mixed>> */
|
||||||
|
public function recentPrints(int $limit = 10): array
|
||||||
|
{
|
||||||
|
return $this->cache->remember('bambuddy.archives.'.md5($this->url()).'.'.$limit, 120, fn (): array => collect($this->get('archives/', ['limit' => $limit]))
|
||||||
|
->filter(fn ($archive): bool => is_array($archive))->values()->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function stats(): array
|
||||||
|
{
|
||||||
|
return $this->cache->remember('bambuddy.stats.'.md5($this->url()), 300, fn (): array => $this->get('archives/stats'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $query */
|
||||||
|
private function get(string $path, array $query = []): array
|
||||||
|
{
|
||||||
|
if (! $this->configured()) {
|
||||||
|
throw new RuntimeException('BamBuddy API key is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Http::withHeaders(['X-API-Key' => $this->settings->for('bambuddy')['api_key']])
|
||||||
|
->acceptJson()->connectTimeout(3)->timeout(8)
|
||||||
|
->get(rtrim($this->url(), '/').'/api/v1/'.ltrim($path, '/'), $query)->throw()->json() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
private function fetchPrinters(): array
|
private function fetchPrinters(): array
|
||||||
{
|
{
|
||||||
$url = rtrim($this->url(), '/').'/api/v1/printers';
|
$url = rtrim($this->url(), '/').'/api/v1/printers';
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ class Display
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format a number of seconds as a short duration such as "2h 1m". */
|
||||||
|
public function duration(int|float|null $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds === null || $seconds <= 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
$minutes = (int) round($seconds / 60);
|
||||||
|
|
||||||
|
return $minutes >= 60 ? intdiv($minutes, 60).'h '.($minutes % 60).'m' : $minutes.'m';
|
||||||
|
}
|
||||||
|
|
||||||
/** Format an API date or date-time as a readable string, dropping the time when it is midnight. */
|
/** Format an API date or date-time as a readable string, dropping the time when it is midnight. */
|
||||||
public function dateTime(string|int|null $value): string
|
public function dateTime(string|int|null $value): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
@section('title', 'Print farm')
|
@section('title', 'Print farm')
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@php $display = app(\App\Services\Display::class); @endphp
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Production</p><h2 class="mt-2 text-2xl font-bold">BamBuddy printers</h2><p class="mt-1 text-slate-400">Live state, progress and temperatures from the workshop.</p></div><a href="{{ $bambuddyUrl }}" target="_blank" rel="noopener noreferrer" class="rounded-xl border border-brand/40 px-4 py-3 text-sm font-semibold text-brand hover:bg-brand/10">Open BamBuddy ↗</a></div>
|
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Production</p><h2 class="mt-2 text-2xl font-bold">BamBuddy printers</h2><p class="mt-1 text-slate-400">Live state, progress and temperatures from the workshop.</p></div><a href="{{ $bambuddyUrl }}" target="_blank" rel="noopener noreferrer" class="rounded-xl border border-brand/40 px-4 py-3 text-sm font-semibold text-brand hover:bg-brand/10">Open BamBuddy ↗</a></div>
|
||||||
@if($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endif
|
@if($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endif
|
||||||
<div class="grid gap-4 lg:grid-cols-2">@forelse($printers as $printer)
|
<div class="grid gap-4 lg:grid-cols-2">@forelse($printers as $printer)
|
||||||
@php $live = $printer['live'] ?? []; $progress = min(100, max(0, (float) ($live['progress'] ?? 0))); $printerErrors = is_array($live['hms_errors'] ?? null) ? $live['hms_errors'] : []; @endphp
|
@php $live = $printer['live'] ?? []; $progress = min(100, max(0, (float) ($live['progress'] ?? 0))); $printerErrors = is_array($live['hms_errors'] ?? null) ? $live['hms_errors'] : []; @endphp
|
||||||
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><div class="flex justify-between gap-3"><div><h3 class="text-lg font-semibold">{{ $printer['name'] ?? 'Printer' }}</h3><p class="text-sm text-slate-400">{{ $printer['model'] ?? 'Bambu printer' }}</p></div><span @class(['h-fit rounded-full px-3 py-1 text-xs font-semibold', 'bg-brand/10 text-brand' => ($live['connected'] ?? false), 'bg-amber-500/10 text-amber-200' => ! ($live['connected'] ?? false)])>{{ $live['state'] ?? $printer['status'] ?? 'Unavailable' }}</span></div>
|
<article class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><div class="flex justify-between gap-3"><div><h3 class="text-lg font-semibold">{{ $printer['name'] ?? 'Printer' }}</h3><p class="text-sm text-slate-400">{{ $printer['model'] ?? 'Bambu printer' }}</p></div><span @class(['h-fit rounded-full px-3 py-1 text-xs font-semibold', 'bg-brand/10 text-brand' => ($live['connected'] ?? false), 'bg-amber-500/10 text-amber-200' => ! ($live['connected'] ?? false)])>{{ $live['state'] ?? $printer['status'] ?? 'Unavailable' }}</span></div>
|
||||||
<div class="mt-6 flex items-end justify-between gap-4"><div><p class="text-sm text-slate-400">Current job</p><p class="mt-1 max-w-sm truncate font-medium">{{ $live['current_print'] ?? $live['subtask_name'] ?? 'No active job' }}</p></div><strong class="text-2xl">{{ number_format($progress, 0) }}%</strong></div>
|
<div class="mt-6 flex items-end justify-between gap-4"><div><p class="text-sm text-slate-400">Current job</p><p class="mt-1 max-w-sm truncate font-medium">{{ $live['current_print'] ?? $live['subtask_name'] ?? 'No active job' }}</p></div><strong class="text-2xl">{{ number_format($progress, 0) }}%</strong></div>@if(! empty($live['total_layers']))<p class="mt-1 text-xs text-slate-400">Layer {{ $live['layer_num'] ?? 0 }} of {{ $live['total_layers'] }}</p>@endif
|
||||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-800"><div class="h-full rounded-full bg-brand" style="width: {{ $progress }}%"></div></div>
|
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-800"><div class="h-full rounded-full bg-brand" style="width: {{ $progress }}%"></div></div>
|
||||||
<dl class="mt-5 grid grid-cols-3 gap-3 text-sm"><div class="rounded-xl bg-slate-950 p-3"><dt class="text-slate-400">Nozzle</dt><dd class="mt-1 font-semibold">{{ isset($live['temperatures']['nozzle']) ? $live['temperatures']['nozzle'].'°C' : '—' }}</dd></div><div class="rounded-xl bg-slate-950 p-3"><dt class="text-slate-400">Bed</dt><dd class="mt-1 font-semibold">{{ isset($live['temperatures']['bed']) ? $live['temperatures']['bed'].'°C' : '—' }}</dd></div><div class="rounded-xl bg-slate-950 p-3"><dt class="text-slate-400">Remaining</dt><dd class="mt-1 font-semibold">{{ isset($live['remaining_time']) ? $live['remaining_time'].' min' : '—' }}</dd></div></dl>
|
<dl class="mt-5 grid grid-cols-3 gap-3 text-sm"><div class="rounded-xl bg-slate-950 p-3"><dt class="text-slate-400">Nozzle</dt><dd class="mt-1 font-semibold">{{ isset($live['temperatures']['nozzle']) ? $live['temperatures']['nozzle'].'°C' : '—' }}</dd></div><div class="rounded-xl bg-slate-950 p-3"><dt class="text-slate-400">Bed</dt><dd class="mt-1 font-semibold">{{ isset($live['temperatures']['bed']) ? $live['temperatures']['bed'].'°C' : '—' }}</dd></div><div class="rounded-xl bg-slate-950 p-3"><dt class="text-slate-400">Remaining</dt><dd class="mt-1 font-semibold">{{ isset($live['remaining_time']) ? $display->duration($live['remaining_time'] * 60) : '—' }}</dd></div></dl>
|
||||||
|
@foreach(($live['ams'] ?? []) as $unit)
|
||||||
|
<div class="mt-5"><div class="flex items-center justify-between text-xs text-slate-400"><span>{{ ($unit['is_ams_ht'] ?? false) ? 'AMS HT' : 'AMS' }} {{ ($unit['id'] ?? 0) + 1 }}</span><span>@if(isset($unit['humidity']))Humidity {{ $unit['humidity'] }}%@endif @if(isset($unit['temp'])) · {{ $unit['temp'] }}°C @endif</span></div>
|
||||||
|
<div class="mt-2 grid grid-cols-4 gap-2">@foreach(($unit['tray'] ?? []) as $tray)@php $loaded = ($tray['exists'] ?? false) && filled($tray['tray_type'] ?? null); $colour = preg_match('/^[0-9A-Fa-f]{6}/', (string) ($tray['tray_color'] ?? '')) ? '#'.substr($tray['tray_color'], 0, 6) : '#1e293b'; $remain = (int) ($tray['remain'] ?? 0); @endphp
|
||||||
|
<div class="rounded-xl bg-slate-950 p-2 text-center text-xs">@if($loaded)<span class="mx-auto block h-6 w-6 rounded-full border border-slate-600" style="background: {{ $colour }}"></span><p class="mt-1 font-semibold">{{ $tray['tray_type'] }}</p><p class="text-slate-400">{{ $remain > 0 ? $remain.'%' : '—' }}</p>@else<span class="mx-auto block h-6 w-6 rounded-full border border-dashed border-slate-700"></span><p class="mt-1 text-slate-500">Empty</p>@endif</div>@endforeach</div></div>
|
||||||
|
@endforeach
|
||||||
@if(count($printerErrors))<p class="mt-4 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-200">{{ count($printerErrors) }} printer alert{{ count($printerErrors) === 1 ? '' : 's' }} · {{ $printerErrors[0]['description'] ?? 'Open BamBuddy for details' }}</p>@endif
|
@if(count($printerErrors))<p class="mt-4 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-200">{{ count($printerErrors) }} printer alert{{ count($printerErrors) === 1 ? '' : 's' }} · {{ $printerErrors[0]['description'] ?? 'Open BamBuddy for details' }}</p>@endif
|
||||||
</article>
|
</article>
|
||||||
@empty<div class="rounded-2xl border border-dashed border-slate-700 p-10 text-slate-400">No printers returned by BamBuddy.</div>@endforelse</div>
|
@empty<div class="rounded-2xl border border-dashed border-slate-700 p-10 text-slate-400">No printers returned by BamBuddy.</div>@endforelse</div>
|
||||||
|
|
||||||
|
@if(! $integrationError)
|
||||||
|
<section class="rounded-2xl border border-slate-800 bg-slate-900"><div class="flex items-center justify-between gap-3 p-6"><div><h3 class="text-lg font-semibold">Print queue</h3><p class="mt-1 text-sm text-slate-400">Jobs waiting for a printer.</p></div><span class="rounded-full bg-brand/10 px-3 py-1 text-xs font-semibold text-brand">{{ count($queue) }} queued</span></div>
|
||||||
|
@forelse($queue as $job)<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-6 py-4 text-sm"><div class="min-w-0"><p class="truncate font-medium">{{ $job['archive_name'] ?? $job['library_file_name'] ?? 'Print job' }}</p><p class="mt-1 text-xs text-slate-400">{{ $job['printer_name'] ?? $job['target_model'] ?? 'Any printer' }}@if(! empty($job['filament_type'])) · {{ $job['filament_type'] }}@endif @if(! empty($job['print_time_seconds'])) · {{ $display->duration($job['print_time_seconds']) }}@endif</p></div><span class="text-xs text-slate-400">{{ $job['waiting_reason'] ?? ucfirst((string) ($job['status'] ?? 'pending')) }}</span></div>
|
||||||
|
@empty<p class="border-t border-slate-800 px-6 py-6 text-sm text-slate-400">The queue is empty.</p>@endforelse</section>
|
||||||
|
@if($stats)<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">@foreach([['Total prints', $stats['total_prints'] ?? 0], ['Successful', $stats['successful_prints'] ?? 0], ['Failed', $stats['failed_prints'] ?? 0], ['Print time', number_format((float) ($stats['total_print_time_hours'] ?? 0), 1).' h'], ['Filament used', number_format((float) ($stats['total_filament_grams'] ?? 0) / 1000, 2).' kg']] as [$label, $value])<div class="rounded-2xl border border-slate-800 bg-slate-900 p-5"><p class="text-sm text-slate-400">{{ $label }}</p><p class="mt-2 text-2xl font-bold">{{ $value }}</p></div>@endforeach</div>@endif
|
||||||
|
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900"><div class="p-6"><h3 class="text-lg font-semibold">Recent prints</h3><p class="mt-1 text-sm text-slate-400">Latest archived jobs from BamBuddy.</p></div>
|
||||||
|
<table class="w-full min-w-[800px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Print</th><th class="p-4">Printer</th><th class="p-4">Status</th><th class="p-4">Filament</th><th class="p-4">Duration</th><th class="p-4">Completed</th></tr></thead><tbody class="divide-y divide-slate-800">
|
||||||
|
@forelse($recentPrints as $archive)@php $status = (string) ($archive['status'] ?? 'unknown'); @endphp<tr><td class="p-4 font-medium">{{ $archive['print_name'] ?? $archive['filename'] ?? 'Print' }}</td><td class="p-4">{{ collect($printers)->firstWhere('id', $archive['printer_id'] ?? null)['name'] ?? '—' }}</td><td class="p-4"><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $status === 'completed', 'bg-rose-500/10 text-rose-300' => $status === 'failed', 'bg-amber-500/10 text-amber-200' => ! in_array($status, ['completed', 'failed'], true)])>{{ ucfirst($status) }}</span></td><td class="p-4">{{ $archive['filament_type'] ?? '—' }}@if(! empty($archive['filament_used_grams'])) · {{ number_format((float) $archive['filament_used_grams'], 0) }} g @endif</td><td class="p-4">{{ $display->duration($archive['actual_time_seconds'] ?? $archive['print_time_seconds'] ?? null) }}</td><td class="p-4">{{ $display->dateTime($archive['completed_at'] ?? $archive['started_at'] ?? null) }}</td></tr>
|
||||||
|
@empty<tr><td colspan="6" class="p-6 text-slate-400">No archived prints yet.</td></tr>@endforelse</tbody></table></section>
|
||||||
|
@endif
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class BambuddyPageTest extends TestCase
|
||||||
|
{
|
||||||
|
use LazilyRefreshDatabase;
|
||||||
|
|
||||||
|
public function test_print_farm_shows_ams_queue_history_and_stats(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake([
|
||||||
|
'https://3d.example.test/api/v1/printers/1/status' => Http::response([
|
||||||
|
'connected' => true, 'state' => 'RUNNING', 'progress' => 40.0, 'remaining_time' => 125,
|
||||||
|
'layer_num' => 52, 'total_layers' => 130, 'current_print' => 'Nozzle_Holder',
|
||||||
|
'temperatures' => ['nozzle' => 260.0, 'bed' => 90.0],
|
||||||
|
'ams' => [['id' => 0, 'humidity' => 40, 'temp' => 27.1, 'is_ams_ht' => false, 'tray' => [
|
||||||
|
['id' => 0, 'tray_color' => '489FDFFF', 'tray_type' => 'ABS', 'remain' => 36, 'exists' => true],
|
||||||
|
['id' => 1, 'tray_color' => null, 'tray_type' => null, 'remain' => 0, 'exists' => false],
|
||||||
|
]]],
|
||||||
|
]),
|
||||||
|
'https://3d.example.test/api/v1/printers' => Http::response([['id' => 1, 'name' => 'Printy Boii 2', 'model' => 'X1C']]),
|
||||||
|
'https://3d.example.test/api/v1/queue/' => Http::response([
|
||||||
|
['id' => 5, 'status' => 'pending', 'position' => 1, 'archive_name' => 'Queued_Bracket', 'completed_at' => null],
|
||||||
|
['id' => 4, 'status' => 'completed', 'position' => 0, 'archive_name' => 'Old_Job', 'completed_at' => '2026-10-01T10:00:00'],
|
||||||
|
]),
|
||||||
|
'https://3d.example.test/api/v1/archives/stats' => Http::response(['total_prints' => 36, 'successful_prints' => 32, 'failed_prints' => 4, 'total_print_time_hours' => 81.5, 'total_filament_grams' => 1937.1]),
|
||||||
|
'https://3d.example.test/api/v1/archives/*' => Http::response([[
|
||||||
|
'id' => 36, 'printer_id' => 1, 'print_name' => 'Finished_Vase', 'status' => 'completed', 'filament_type' => 'ABS',
|
||||||
|
'filament_used_grams' => 49.94, 'actual_time_seconds' => 7265, 'completed_at' => '2026-10-11T09:13:01.375546',
|
||||||
|
]]),
|
||||||
|
]);
|
||||||
|
config()->set('services.bambuddy', ['url' => 'https://3d.example.test', 'api_key' => 'key']);
|
||||||
|
$admin = User::factory()->create(['role' => 'admin']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->get(route('printers.index'))->assertOk()
|
||||||
|
->assertSee('Layer 52 of 130')->assertSee('2h 5m')->assertSee('Humidity 40%')->assertSee('Empty')
|
||||||
|
->assertSee('Queued_Bracket')->assertDontSee('Old_Job')->assertSee('1 queued')
|
||||||
|
->assertSee('Finished_Vase')->assertSee('2h 1m')->assertSee('Sun 11 Oct 2026, 09:13')->assertSee('81.5 h');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user