diff --git a/README.md b/README.md index b4cef4c..63db565 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HickLab3D dashboard -Laravel 13, Tailwind CSS 4, and MySQL dashboard for the HickLab3D workshop. It includes user accounts, Bambu printer telemetry, Amazon filament price tracking, Parcel2Go orders, and ESP32 filament dryer control. +Laravel 13, Tailwind CSS 4, and MySQL dashboard for the HickLab3D workshop. It includes user accounts, Etsy shop data, BamBuddy printer status, Parcel2Go orders, and filament dryer control. ## Setup @@ -28,20 +28,12 @@ The admin command prompts for a password. Never pass it on the command line. For The migrations use the legacy table and column names. If you point the app at the existing TechOdyssey database, back it up first. The migrations skip tables already present and create Laravel's session, cache, and job tables. Existing schema variations have not been verified against a database dump. Do not run `migrate:fresh` on a database with production data. -## Integrations and scheduled work +## Integrations - Parcel2Go: set `P2G_CLIENT_ID`, `P2G_CLIENT_SECRET`, and `P2G_API_AUTH_URL`. -- Dryer: set `DRYER_URL` to the ESP32 gateway base URL. The browser talks to authenticated Laravel routes; Laravel forwards status and control requests. -- Printers: set `MQTT_PORT`, `MQTT_USERNAME`, and `MQTT_VERIFY_TLS`. Each printer's access code is entered when adding it. `printers:collect` samples telemetry from each printer. -- Filament: `filaments:refresh` reads tracked Amazon listings and sends price drop mail to enabled recipients. Configure Laravel's `MAIL_*` settings for delivery. Amazon may change or block its public product HTML, in which case a listing is skipped. - -Run Laravel's scheduler every minute on the server: - -```sh -php artisan schedule:run -``` - -The schedule collects printer telemetry every five minutes and refreshes filament prices hourly. You can run either command manually for diagnosis. +- Etsy: add the app keystring and shared secret on the Settings page, then connect the shop with OAuth. +- Printers: set `BAMBUDDY_URL` and `BAMBUDDY_API_KEY` for live print farm status. +- Dryer: set `DRYER_URL` and `DRYER_API_KEY`. The browser talks to authenticated Laravel routes; Laravel forwards status and control requests. ## Verification diff --git a/app/Console/Commands/RefreshFilamentPrices.php b/app/Console/Commands/RefreshFilamentPrices.php deleted file mode 100644 index a7b5ff0..0000000 --- a/app/Console/Commands/RefreshFilamentPrices.php +++ /dev/null @@ -1,67 +0,0 @@ -amazonUrl, PHP_URL_HOST)); - if (! in_array($host, ['amazon.co.uk', 'www.amazon.co.uk', 'amazon.com', 'www.amazon.com'], true)) { - $this->warn($filament->filamentName.': unsupported product host'); - - continue; - } - try { - $html = Http::timeout(15)->withHeaders(['User-Agent' => 'Mozilla/5.0 HickLab3D/1.0']) - ->get($filament->amazonUrl)->throw()->body(); - if (! preg_match('/class="[^"]*a-price-whole[^"]*"[^>]*>([0-9,.]+)/', $html, $whole) - || ! preg_match('/class="[^"]*a-price-fraction[^"]*"[^>]*>([0-9]{2})/', $html, $fraction)) { - $this->warn($filament->filamentName.': price unavailable'); - - continue; - } - $price = (float) (str_replace(',', '', $whole[1]).'.'.$fraction[1]); - if ($price <= 0) { - continue; - } - preg_match('/class="[^"]*savingsPercentage[^"]*"[^>]*>[^0-9]*([0-9]+)%/', $html, $discount); - $previous = FilamentPrice::where('filamentId', $filament->id)->orderByDesc('recordedAt')->orderByDesc('id')->first(); - FilamentPrice::create([ - 'filamentId' => $filament->id, - 'price' => $price, - 'currentDiscount' => ['discount' => ['value' => (int) ($discount[1] ?? 0), 'type' => 'percentage'], 'voucher' => ['value' => 0, 'type' => 'none']], - ]); - if ($previous && $price < (float) $previous->price) { - $drops[] = $filament->filamentName.': £'.number_format((float) $previous->price, 2).' → £'.number_format($price, 2).' ('.$filament->amazonUrl.')'; - } - $this->line($filament->filamentName.': £'.number_format($price, 2)); - } catch (Throwable $exception) { - report($exception); - $this->warn($filament->filamentName.': collection failed'); - } - } - if ($drops) { - $body = "HickLab3D filament price drops\n\n".implode("\n", $drops); - foreach (User::where('alertEmails', true)->where('disabled', false)->pluck('email') as $email) { - Mail::raw($body, fn ($message) => $message->to($email)->subject('HickLab3D filament price drops')); - } - } - - return self::SUCCESS; - } -} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 6be1bb9..487d49a 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers; use App\Models\EtsyConnection; -use App\Models\Filament; use App\Services\Bambuddy; use App\Services\DryerClient; use App\Services\Etsy; @@ -18,16 +17,6 @@ class DashboardController extends Controller public function __invoke(Request $request, Etsy $etsy, Bambuddy $bambuddy, Parcel2Go $parcel2Go, DryerClient $dryer): View { $isAdmin = $request->user()->role === 'admin'; - $filamentCount = Filament::count(); - $recentFilaments = Filament::with(['prices' => fn ($query) => $query->orderByDesc('recordedAt')->limit(1)]) - ->orderByDesc('id')->limit(4)->get(); - $materials = Filament::query()->select('material')->selectRaw('COUNT(*) as total') - ->groupBy('material')->orderByDesc('total')->get() - ->map(fn (Filament $filament): array => [ - 'label' => $filament->material ?: 'Unspecified', - 'count' => (int) $filament->total, - ])->all(); - $etsyStatus = $isAdmin ? 'Not connected' : null; $etsyConnected = false; $receipts = []; @@ -75,11 +64,25 @@ class DashboardController extends Controller } } - $printingCount = collect($printers)->filter(fn (array $printer): bool => in_array( - strtoupper((string) ($printer['live']['state'] ?? $printer['status'] ?? '')), - ['RUNNING', 'PRINTING'], - true, - ))->count(); + $printerStates = collect($printers)->countBy(function (array $printer): string { + $live = $printer['live'] ?? []; + $state = strtoupper((string) ($live['state'] ?? $printer['status'] ?? '')); + + if ($live === []) { + return 'Unavailable'; + } + if (($live['connected'] ?? false) === false) { + return 'Offline'; + } + if (in_array($state, ['RUNNING', 'PRINTING'], true)) { + return 'Printing'; + } + if (in_array($state, ['IDLE', 'READY', 'FINISH'], true)) { + return 'Idle'; + } + + return 'Other'; + }); $dryerState = 'Not configured'; if (filled(config('services.dryer.api_key'))) { @@ -99,9 +102,6 @@ class DashboardController extends Controller return view('dashboard', [ 'isAdmin' => $isAdmin, - 'filamentCount' => $filamentCount, - 'recentFilaments' => $recentFilaments, - 'materials' => $materials, 'receipts' => array_slice($receipts, 0, 5), 'awaitingDispatch' => count($receipts), 'listingCount' => $listingCount, @@ -111,7 +111,11 @@ class DashboardController extends Controller 'shippingStatus' => $shippingStatus, 'printers' => $printers, 'printerStatus' => $printerStatus, - 'printingCount' => $printingCount, + 'printingCount' => $printerStates->get('Printing', 0), + 'printerChart' => $printerStates->map(fn (int $count, string $state): array => [ + 'label' => $state, + 'count' => $count, + ])->values()->all(), 'dryerState' => $dryerState, 'orderChart' => [ 'labels' => $days->map(fn (Carbon $day): string => $day->format('d M'))->all(), diff --git a/app/Http/Controllers/FilamentController.php b/app/Http/Controllers/FilamentController.php deleted file mode 100644 index 5e46343..0000000 --- a/app/Http/Controllers/FilamentController.php +++ /dev/null @@ -1,52 +0,0 @@ - Filament::with(['prices' => fn ($query) => $query->orderByDesc('recordedAt')->limit(10)])->orderBy('filamentName')->paginate(20)]); - } - - public function create(Request $request): View - { - abort_unless($request->user()->role === 'admin', 403); - - return view('filaments.create'); - } - - public function store(Request $request): RedirectResponse - { - abort_unless($request->user()->role === 'admin', 403); - $data = $request->validate([ - 'filamentName' => ['required', 'string', 'max:255'], - 'amazonUrl' => ['required', 'url:http,https', 'max:2048'], - 'brand' => ['nullable', 'string', 'max:255'], - 'material' => ['nullable', 'string', 'max:255'], - 'color' => ['nullable', 'string', 'max:255'], - 'filamentWeight' => ['nullable', 'numeric', 'min:0'], - 'itemDiameter' => ['nullable', 'numeric', 'min:0'], - ]); - $host = strtolower((string) parse_url($data['amazonUrl'], PHP_URL_HOST)); - if (! in_array($host, ['amazon.co.uk', 'www.amazon.co.uk', 'amazon.com', 'www.amazon.com'], true)) { - return back()->withErrors(['amazonUrl' => 'Enter an Amazon UK or US product URL.'])->withInput(); - } - Filament::create([...$data, 'userId' => $request->user()->id]); - - return redirect()->route('filaments.index')->with('status', 'Filament added. Price collection runs on the schedule.'); - } - - public function destroy(Request $request, Filament $filament): RedirectResponse - { - abort_unless($request->user()->role === 'admin', 403); - $filament->delete(); - - return redirect()->route('filaments.index')->with('status', 'Filament removed.'); - } -} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 0299d92..30eb441 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -25,7 +25,6 @@ class UserController extends Controller 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'role' => ['required', Rule::in(['admin', 'user'])], 'password' => ['required', 'string', 'min:12'], - 'alertEmails' => ['sometimes', 'boolean'], ]); User::create($data); @@ -39,7 +38,6 @@ class UserController extends Controller 'email' => ['sometimes', 'email', Rule::unique('users', 'email')->ignore($user->id)], 'role' => ['sometimes', Rule::in(['admin', 'user'])], 'disabled' => ['sometimes', 'boolean'], - 'alertEmails' => ['sometimes', 'boolean'], 'password' => ['nullable', 'string', 'min:12'], ]); if (empty($data['password'])) { diff --git a/app/Models/Filament.php b/app/Models/Filament.php deleted file mode 100644 index f7316b1..0000000 --- a/app/Models/Filament.php +++ /dev/null @@ -1,25 +0,0 @@ - */ - use HasFactory; - - protected $table = 'filamentTracker'; - - public $timestamps = false; - - protected $fillable = ['userId', 'filamentName', 'amazonUrl', 'filamentWeight', 'brand', 'material', 'color', 'itemDiameter']; - - public function prices(): HasMany - { - return $this->hasMany(FilamentPrice::class, 'filamentId'); - } -} diff --git a/app/Models/FilamentPrice.php b/app/Models/FilamentPrice.php deleted file mode 100644 index 25d1e64..0000000 --- a/app/Models/FilamentPrice.php +++ /dev/null @@ -1,24 +0,0 @@ - */ - use HasFactory; - - protected $table = 'filamentPriceHistory'; - - public $timestamps = false; - - protected $fillable = ['filamentId', 'price', 'currentDiscount']; - - protected function casts(): array - { - return ['currentDiscount' => 'array', 'recordedAt' => 'datetime', 'price' => 'decimal:2']; - } -} diff --git a/app/Models/User.php b/app/Models/User.php index c21ea68..454d0f1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -#[Fillable(['username', 'email', 'role', 'disabled', 'alertEmails', 'password'])] +#[Fillable(['username', 'email', 'role', 'disabled', 'password'])] #[Hidden(['password'])] class User extends Authenticatable { @@ -29,7 +29,6 @@ class User extends Authenticatable return [ 'password' => 'hashed', 'disabled' => 'boolean', - 'alertEmails' => 'boolean', ]; } } diff --git a/database/factories/FilamentFactory.php b/database/factories/FilamentFactory.php deleted file mode 100644 index dd0aff3..0000000 --- a/database/factories/FilamentFactory.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ -class FilamentFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - // - ]; - } -} diff --git a/database/factories/FilamentPriceFactory.php b/database/factories/FilamentPriceFactory.php deleted file mode 100644 index 271c2e8..0000000 --- a/database/factories/FilamentPriceFactory.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ -class FilamentPriceFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - // - ]; - } -} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index a1c8072..653b505 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -28,7 +28,6 @@ class UserFactory extends Factory 'email' => fake()->unique()->safeEmail(), 'role' => 'user', 'disabled' => false, - 'alertEmails' => false, 'password' => static::$password ??= Hash::make('password'), ]; } diff --git a/resources/js/app.js b/resources/js/app.js index 9dd3b80..5415651 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -41,15 +41,15 @@ if (document.querySelector('[data-dashboard-chart]')) { markers: { size: 3 }, legend: { show: false }, }; - } else if (element.dataset.dashboardChart === 'materials') { + } else if (element.dataset.dashboardChart === 'printers') { options = { ...chartBase, chart: { ...chartBase.chart, type: 'donut', height }, labels: data.map((item) => item.label), series: data.map((item) => item.count), - colors: ['#e3f504', '#7dd3a3', '#8b9df0', '#f0a46b', '#cc9df0', '#66cbd1', '#f17fa5'], + colors: ['#e3f504', '#7dd3a3', '#f0a46b', '#f17fa5', '#8b9df0'], legend: { position: 'bottom', fontSize: '12px', labels: { colors: '#b8bec4' } }, - plotOptions: { pie: { donut: { size: '68%', labels: { show: true, total: { show: true, label: 'Spools' } } } } }, + plotOptions: { pie: { donut: { size: '68%', labels: { show: true, total: { show: true, label: 'Printers' } } } } }, dataLabels: { enabled: false }, stroke: { colors: ['#17191b'], width: 2 }, }; diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index f76b37e..5f8df14 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -10,7 +10,7 @@

Updated when you open this page

-
+
$isAdmin]) aria-label="Key metrics"> @if($isAdmin)

Awaiting dispatch

↗
@@ -23,24 +23,18 @@

{{ $printerStatus === 'Live from BamBuddy' ? $printingCount : '—' }}/ {{ $printerStatus === 'Live from BamBuddy' ? count($printers) : '—' }}

{{ $printerStatus }}

- -

Tracked materials

↗
-

{{ $filamentCount }}

-

{{ count($materials) }} material types in your tracker

-
@if($isAdmin)

Active listings

↗

{{ $listingCount ?? '—' }}

{{ $etsyStatus }}

- @else + @endif

Filament dryer

↗

{{ $dryerState }}

Current workshop state

- @endif
@@ -58,11 +52,11 @@
@endif
-

Material mix

Tracked filaments by material

- @if($filamentCount) -
+

Print farm status

Live printer states from BamBuddy

+ @if(count($printerChart)) +
@else -

No materials tracked yet

Open filament tracker →
+

No printer status to chart

Open print farm →
@endif
@unless($isAdmin) @@ -96,8 +90,8 @@ @endif -
- @if($isAdmin) +@if($isAdmin) +

Recent shipments

Parcel2Go · {{ $shippingStatus }}

Labels & collections →
@@ -106,16 +100,7 @@ @empty

{{ $shippingStatus === 'Live account' ? 'No recent shipments.' : $shippingStatus }}

@endforelse
- @endif -
-

Filament tracker

Most recently tracked materials

View all →
-
- @forelse($recentFilaments as $filament) -

{{ $filament->filamentName }}

{{ $filament->brand ?: 'Unknown brand' }} · {{ $filament->material ?: 'Material pending' }}

{{ $filament->prices->first() ? '£'.number_format($filament->prices->first()->price, 2) : 'Pending' }}
- @empty

No filament is being tracked yet.

@endforelse -
-
+@endif -

Dryer status

{{ $dryerState }}

Open controls →
@endsection diff --git a/resources/views/filaments/create.blade.php b/resources/views/filaments/create.blade.php deleted file mode 100644 index cd72e1b..0000000 --- a/resources/views/filaments/create.blade.php +++ /dev/null @@ -1,10 +0,0 @@ -@extends('layouts.app') -@section('title', 'Track filament') -@section('content') -
@csrf - @foreach([['filamentName','Filament name','e.g. PLA Matte Black'],['amazonUrl','Product URL','https://www.amazon.co.uk/...'],['brand','Brand','Optional'],['material','Material','PLA, PETG, ABS…'],['color','Colour','Optional'],['filamentWeight','Weight (g)','1000'],['itemDiameter','Diameter (mm)','1.75']] as [$name,$label,$placeholder]) - - @endforeach -
Cancel
-
-@endsection diff --git a/resources/views/filaments/index.blade.php b/resources/views/filaments/index.blade.php deleted file mode 100644 index 918aa14..0000000 --- a/resources/views/filaments/index.blade.php +++ /dev/null @@ -1,10 +0,0 @@ -@extends('layouts.app') -@section('title', 'Filament tracker') -@section('content') -

Filament prices

Recent prices and offers from tracked listings.

@if(auth()->user()->role === 'admin')Track filament@endif
-
@forelse($filaments as $filament) - @php $latest = $filament->prices->first(); $discount = $latest?->currentDiscount['discount']['value'] ?? 0; @endphp - -@empty@endforelse
FilamentMaterialLatest priceDiscountUpdated
{{ $filament->filamentName }} ↗

{{ $filament->brand ?: 'Unknown brand' }} · {{ $filament->color ?: 'Colour unknown' }}

{{ $filament->material ?: '—' }}{{ $latest ? '£'.number_format($latest->price, 2) : 'Pending' }}{{ $discount ? $discount.'%' : '—' }}{{ $latest?->recordedAt?->diffForHumans() ?? '—' }}@if(auth()->user()->role === 'admin')
@csrf @method('DELETE')
@endif
No filament is being tracked yet.
-{{ $filaments->links() }} -@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index d901e89..57cc54d 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -20,7 +20,7 @@
HickLab3DOperations studio