diff --git a/app/Livewire/MetricsDashboard.php b/app/Livewire/MetricsDashboard.php new file mode 100644 index 000000000..56652112f --- /dev/null +++ b/app/Livewire/MetricsDashboard.php @@ -0,0 +1,370 @@ +startDate)) { + $this->startDate = $this->getDefaultStartDate()->format('Y-m-d'); + } + + if (empty($this->endDate)) { + $this->endDate = now()->format('Y-m-d'); + } + + $this->lastResultId = Result::whereIn('status', [ResultStatus::Completed, ResultStatus::Failed]) + ->latest() + ->value('id'); + } + + protected function getDefaultStartDate(): Carbon + { + $chartRange = config('speedtest.default_chart_range'); + + return match ($chartRange) { + '24h' => now()->subDay(), + 'week' => now()->subWeek(), + 'month' => now()->subMonth(), + default => now()->subWeek(), + }; + } + + public function updatedStartDate(): void + { + $this->dispatch('charts-updated', chartData: $this->getChartData()); + } + + public function updatedEndDate(): void + { + $this->dispatch('charts-updated', chartData: $this->getChartData()); + } + + public function setLastDay(): void + { + $this->startDate = now()->subDay()->format('Y-m-d'); + $this->endDate = now()->format('Y-m-d'); + $this->dispatch('charts-updated', chartData: $this->getChartData()); + } + + public function setLastWeek(): void + { + $this->startDate = now()->subWeek()->format('Y-m-d'); + $this->endDate = now()->format('Y-m-d'); + $this->dispatch('charts-updated', chartData: $this->getChartData()); + } + + public function setLastMonth(): void + { + $this->startDate = now()->subMonth()->format('Y-m-d'); + $this->endDate = now()->format('Y-m-d'); + $this->dispatch('charts-updated', chartData: $this->getChartData()); + } + + public function checkForNewResults(): void + { + $latestResultId = Result::whereIn('status', [ResultStatus::Completed, ResultStatus::Failed]) + ->latest() + ->value('id'); + + if ($latestResultId && $latestResultId !== $this->lastResultId) { + $this->lastResultId = $latestResultId; + $this->refreshCharts(); + } + } + + public function refreshCharts(): void + { + $this->dispatch('charts-updated', chartData: $this->getChartData()); + } + + public function getChartData(): array + { + $startDate = \Carbon\Carbon::parse($this->startDate)->startOfDay(); + $endDate = \Carbon\Carbon::parse($this->endDate)->endOfDay(); + + $results = Result::whereIn('status', [ResultStatus::Completed, ResultStatus::Failed]) + ->whereBetween('created_at', [$startDate, $endDate]) + ->orderBy('created_at') + ->get(); + + // Determine label format based on date range duration + $daysDifference = $startDate->diffInDays($endDate); + $labelFormat = match (true) { + $daysDifference <= 1 => 'g:i A', // 3:45 PM + $daysDifference <= 7 => 'D g:i A', // Mon 3:45 PM + default => 'M j g:i A', // Dec 15 3:45 PM + }; + + $labels = []; + $resultIds = []; + $downloadData = []; + $uploadData = []; + $pingData = []; + $downloadLatencyData = []; + $uploadLatencyData = []; + $downloadJitterData = []; + $uploadJitterData = []; + $pingJitterData = []; + $packetLossData = []; + $downloadBenchmarkFailed = []; + $uploadBenchmarkFailed = []; + $pingBenchmarkFailed = []; + $downloadBenchmarks = []; + $uploadBenchmarks = []; + $pingBenchmarks = []; + $resultStatusFailed = []; + + foreach ($results as $result) { + // Format timestamp for label + $labels[] = $result->created_at->format($labelFormat); + + // Store result ID + $resultIds[] = $result->id; + + // Track if this result has a Failed status + $isFailed = $result->status === ResultStatus::Failed; + $resultStatusFailed[] = $isFailed; + + // Convert download from bytes/sec to Mbps (0 if failed) + $downloadBits = $isFailed ? 0 : Bitrate::bytesToBits($result->download ?? 0); + $downloadData[] = round($downloadBits / 1000000, 2); // Convert to Mbps + + // Convert upload from bytes/sec to Mbps (0 if failed) + $uploadBits = $isFailed ? 0 : Bitrate::bytesToBits($result->upload ?? 0); + $uploadData[] = round($uploadBits / 1000000, 2); // Convert to Mbps + + // Ping in milliseconds (0 if failed) + $pingData[] = $isFailed ? 0 : round($result->ping ?? 0, 2); + + // Latency IQM in milliseconds (0 if failed) + $downloadLatencyData[] = $isFailed ? 0 : round($result->downloadlatencyiqm ?? 0, 2); + $uploadLatencyData[] = $isFailed ? 0 : round($result->uploadlatencyiqm ?? 0, 2); + + // Jitter in milliseconds (0 if failed) + $downloadJitterData[] = $isFailed ? 0 : round($result->downloadJitter ?? 0, 2); + $uploadJitterData[] = $isFailed ? 0 : round($result->uploadJitter ?? 0, 2); + $pingJitterData[] = $isFailed ? 0 : round($result->pingJitter ?? 0, 2); + + // Packet loss in percentage (0 if failed) + $packetLossData[] = $isFailed ? 0 : round($result->packet_loss ?? 0, 2); + + // Track benchmark failures and full benchmark data + $benchmarks = $result->benchmarks ?? []; + $downloadBenchmarkFailed[] = isset($benchmarks['download']) && $benchmarks['download']['passed'] === false; + $uploadBenchmarkFailed[] = isset($benchmarks['upload']) && $benchmarks['upload']['passed'] === false; + $pingBenchmarkFailed[] = isset($benchmarks['ping']) && $benchmarks['ping']['passed'] === false; + + $downloadBenchmarks[] = $benchmarks['download'] ?? null; + $uploadBenchmarks[] = $benchmarks['upload'] ?? null; + $pingBenchmarks[] = $benchmarks['ping'] ?? null; + } + + // Calculate download statistics + $downloadLatest = count($downloadData) > 0 ? end($downloadData) : 0; + $downloadAvg = count($downloadData) > 0 ? round(array_sum($downloadData) / count($downloadData), 2) : 0; + $downloadP95 = $this->calculatePercentile($downloadData, 95); + $downloadMax = count($downloadData) > 0 ? round(max($downloadData), 2) : 0; + $downloadMin = count($downloadData) > 0 ? round(min($downloadData), 2) : 0; + + // Calculate upload statistics + $uploadLatest = count($uploadData) > 0 ? end($uploadData) : 0; + $uploadAvg = count($uploadData) > 0 ? round(array_sum($uploadData) / count($uploadData), 2) : 0; + $uploadP95 = $this->calculatePercentile($uploadData, 95); + $uploadMax = count($uploadData) > 0 ? round(max($uploadData), 2) : 0; + $uploadMin = count($uploadData) > 0 ? round(min($uploadData), 2) : 0; + + // Calculate ping statistics + $pingLatest = count($pingData) > 0 ? end($pingData) : 0; + $pingAvg = count($pingData) > 0 ? round(array_sum($pingData) / count($pingData), 2) : 0; + $pingP95 = $this->calculatePercentile($pingData, 95); + $pingMax = count($pingData) > 0 ? round(max($pingData), 2) : 0; + $pingMin = count($pingData) > 0 ? round(min($pingData), 2) : 0; + + // Calculate jitter statistics + $downloadJitterLatest = count($downloadJitterData) > 0 ? end($downloadJitterData) : 0; + $downloadJitterAvg = count($downloadJitterData) > 0 ? round(array_sum($downloadJitterData) / count($downloadJitterData), 2) : 0; + $downloadJitterP95 = $this->calculatePercentile($downloadJitterData, 95); + $downloadJitterMax = count($downloadJitterData) > 0 ? round(max($downloadJitterData), 2) : 0; + $downloadJitterMin = count($downloadJitterData) > 0 ? round(min($downloadJitterData), 2) : 0; + + $uploadJitterLatest = count($uploadJitterData) > 0 ? end($uploadJitterData) : 0; + $uploadJitterAvg = count($uploadJitterData) > 0 ? round(array_sum($uploadJitterData) / count($uploadJitterData), 2) : 0; + $uploadJitterP95 = $this->calculatePercentile($uploadJitterData, 95); + $uploadJitterMax = count($uploadJitterData) > 0 ? round(max($uploadJitterData), 2) : 0; + $uploadJitterMin = count($uploadJitterData) > 0 ? round(min($uploadJitterData), 2) : 0; + + $pingJitterLatest = count($pingJitterData) > 0 ? end($pingJitterData) : 0; + $pingJitterAvg = count($pingJitterData) > 0 ? round(array_sum($pingJitterData) / count($pingJitterData), 2) : 0; + $pingJitterP95 = $this->calculatePercentile($pingJitterData, 95); + $pingJitterMax = count($pingJitterData) > 0 ? round(max($pingJitterData), 2) : 0; + $pingJitterMin = count($pingJitterData) > 0 ? round(min($pingJitterData), 2) : 0; + + // Calculate latency statistics + $downloadLatencyLatest = count($downloadLatencyData) > 0 ? end($downloadLatencyData) : 0; + $downloadLatencyMax = count($downloadLatencyData) > 0 ? round(max($downloadLatencyData), 2) : 0; + $downloadLatencyMin = count($downloadLatencyData) > 0 ? round(min($downloadLatencyData), 2) : 0; + + $uploadLatencyLatest = count($uploadLatencyData) > 0 ? end($uploadLatencyData) : 0; + $uploadLatencyMax = count($uploadLatencyData) > 0 ? round(max($uploadLatencyData), 2) : 0; + $uploadLatencyMin = count($uploadLatencyData) > 0 ? round(min($uploadLatencyData), 2) : 0; + + // Calculate packet loss statistics + $packetLossLatest = count($packetLossData) > 0 ? end($packetLossData) : 0; + $packetLossAvg = count($packetLossData) > 0 ? round(array_sum($packetLossData) / count($packetLossData), 2) : 0; + $packetLossP95 = $this->calculatePercentile($packetLossData, 95); + $packetLossMax = count($packetLossData) > 0 ? round(max($packetLossData), 2) : 0; + $packetLossMin = count($packetLossData) > 0 ? round(min($packetLossData), 2) : 0; + + // Calculate healthy ratio for each metric based on benchmark KPI + $downloadPassedCount = collect($downloadBenchmarkFailed)->filter(fn ($failed) => $failed === false)->count(); + $uploadPassedCount = collect($uploadBenchmarkFailed)->filter(fn ($failed) => $failed === false)->count(); + $pingPassedCount = collect($pingBenchmarkFailed)->filter(fn ($failed) => $failed === false)->count(); + + $downloadHealthyRatio = count($results) > 0 ? round(($downloadPassedCount / count($results)) * 100, 1) : 0; + $uploadHealthyRatio = count($results) > 0 ? round(($uploadPassedCount / count($results)) * 100, 1) : 0; + $pingHealthyRatio = count($results) > 0 ? round(($pingPassedCount / count($results)) * 100, 1) : 0; + + // Determine if latest stat failed benchmark + $downloadLatestFailed = count($downloadBenchmarkFailed) > 0 ? end($downloadBenchmarkFailed) : false; + $uploadLatestFailed = count($uploadBenchmarkFailed) > 0 ? end($uploadBenchmarkFailed) : false; + $pingLatestFailed = count($pingBenchmarkFailed) > 0 ? end($pingBenchmarkFailed) : false; + + // Get latest benchmark data + $downloadLatestBenchmark = count($downloadBenchmarks) > 0 ? end($downloadBenchmarks) : null; + $uploadLatestBenchmark = count($uploadBenchmarks) > 0 ? end($uploadBenchmarks) : null; + $pingLatestBenchmark = count($pingBenchmarks) > 0 ? end($pingBenchmarks) : null; + + // Check if there are any failed benchmarks in the date range + $hasFailedResults = collect($downloadBenchmarkFailed)->contains(true) || + collect($uploadBenchmarkFailed)->contains(true) || + collect($pingBenchmarkFailed)->contains(true); + + return [ + 'labels' => $labels, + 'resultIds' => $resultIds, + 'hasFailedResults' => $hasFailedResults, + 'resultStatusFailed' => $resultStatusFailed, + 'daysDifference' => $daysDifference, + 'download' => $downloadData, + 'upload' => $uploadData, + 'ping' => $pingData, + 'downloadLatency' => $downloadLatencyData, + 'uploadLatency' => $uploadLatencyData, + 'downloadJitter' => $downloadJitterData, + 'uploadJitter' => $uploadJitterData, + 'pingJitter' => $pingJitterData, + 'packetLoss' => $packetLossData, + 'downloadBenchmarkFailed' => $downloadBenchmarkFailed, + 'uploadBenchmarkFailed' => $uploadBenchmarkFailed, + 'pingBenchmarkFailed' => $pingBenchmarkFailed, + 'downloadBenchmarks' => $downloadBenchmarks, + 'uploadBenchmarks' => $uploadBenchmarks, + 'pingBenchmarks' => $pingBenchmarks, + 'count' => count($results), + 'downloadStats' => [ + 'latest' => $downloadLatest, + 'latestFailed' => $downloadLatestFailed, + 'latestBenchmark' => $downloadLatestBenchmark, + 'average' => $downloadAvg, + 'p95' => $downloadP95, + 'maximum' => $downloadMax, + 'minimum' => $downloadMin, + 'healthy' => $downloadHealthyRatio, + 'tests' => count($results), + ], + 'uploadStats' => [ + 'latest' => $uploadLatest, + 'latestFailed' => $uploadLatestFailed, + 'latestBenchmark' => $uploadLatestBenchmark, + 'average' => $uploadAvg, + 'p95' => $uploadP95, + 'maximum' => $uploadMax, + 'minimum' => $uploadMin, + 'healthy' => $uploadHealthyRatio, + 'tests' => count($results), + ], + 'pingStats' => [ + 'latest' => $pingLatest, + 'latestFailed' => $pingLatestFailed, + 'latestBenchmark' => $pingLatestBenchmark, + 'average' => $pingAvg, + 'p95' => $pingP95, + 'maximum' => $pingMax, + 'minimum' => $pingMin, + 'healthy' => $pingHealthyRatio, + 'tests' => count($results), + ], + 'latencyStats' => [ + 'downloadLatest' => $downloadLatencyLatest, + 'downloadMaximum' => $downloadLatencyMax, + 'downloadMinimum' => $downloadLatencyMin, + 'uploadLatest' => $uploadLatencyLatest, + 'uploadMaximum' => $uploadLatencyMax, + 'uploadMinimum' => $uploadLatencyMin, + 'tests' => count($results), + ], + 'jitterStats' => [ + 'downloadLatest' => $downloadJitterLatest, + 'downloadAverage' => $downloadJitterAvg, + 'downloadP95' => $downloadJitterP95, + 'downloadMaximum' => $downloadJitterMax, + 'downloadMinimum' => $downloadJitterMin, + 'uploadLatest' => $uploadJitterLatest, + 'uploadAverage' => $uploadJitterAvg, + 'uploadP95' => $uploadJitterP95, + 'uploadMaximum' => $uploadJitterMax, + 'uploadMinimum' => $uploadJitterMin, + 'pingLatest' => $pingJitterLatest, + 'pingAverage' => $pingJitterAvg, + 'pingP95' => $pingJitterP95, + 'pingMaximum' => $pingJitterMax, + 'pingMinimum' => $pingJitterMin, + 'tests' => count($results), + ], + 'packetLossStats' => [ + 'latest' => $packetLossLatest, + 'average' => $packetLossAvg, + 'p95' => $packetLossP95, + 'maximum' => $packetLossMax, + 'minimum' => $packetLossMin, + 'tests' => count($results), + ], + ]; + } + + protected function calculatePercentile(array $data, int $percentile): float + { + if (count($data) === 0) { + return 0; + } + + sort($data); + $index = (int) ceil(($percentile / 100) * count($data)) - 1; + $index = max(0, min($index, count($data) - 1)); + + return round($data[$index], 2); + } + + public function render() + { + return view('livewire.metrics-dashboard', [ + 'chartData' => $this->getChartData(), + ]); + } +} diff --git a/app/Models/Result.php b/app/Models/Result.php index 084c04097..1caa1c5ef 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -48,6 +48,14 @@ public function prunable(): Builder return static::where('created_at', '<=', now()->subDays(config('speedtest.prune_results_older_than'))); } + /** + * Scope a query to only include completed results. + */ + public function scopeCompleted(Builder $query): Builder + { + return $query->where('status', ResultStatus::Completed); + } + /** * Get the user who dispatched this speedtest. */ diff --git a/composer.json b/composer.json index 4fe2349e4..210c2da65 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "laravel/framework": "^12.41.1", "laravel/prompts": "^0.3.8", "laravel/sanctum": "^4.2.1", + "livewire/flux": "^2.10.1", "livewire/livewire": "^3.7.1", "lorisleiva/laravel-actions": "^2.9.1", "maennchen/zipstream-php": "^2.4", diff --git a/composer.lock b/composer.lock index 6c492d7d9..1c4d05b29 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": "374762e19dbfc99374c14f3f12a4ae3e", + "content-hash": "42dd567bf4f631b296e0dd9ac5825ca8", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -3574,6 +3574,72 @@ ], "time": "2025-11-18T12:17:23+00:00" }, + { + "name": "livewire/flux", + "version": "v2.10.1", + "source": { + "type": "git", + "url": "https://github.com/livewire/flux.git", + "reference": "11f04bca8cd57e05d594a96188c26f0c118c4c4f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/flux/zipball/11f04bca8cd57e05d594a96188c26f0c118c4c4f", + "reference": "11f04bca8cd57e05d594a96188c26f0c118c4c4f", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/view": "^10.0|^11.0|^12.0", + "laravel/prompts": "^0.1|^0.2|^0.3", + "livewire/livewire": "^3.5.19|^4.0", + "php": "^8.1", + "symfony/console": "^6.0|^7.0" + }, + "conflict": { + "livewire/blaze": "<1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Flux": "Flux\\Flux" + }, + "providers": [ + "Flux\\FluxServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Flux\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "proprietary" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "The official UI component library for Livewire.", + "keywords": [ + "components", + "flux", + "laravel", + "livewire", + "ui" + ], + "support": { + "issues": "https://github.com/livewire/flux/issues", + "source": "https://github.com/livewire/flux/tree/v2.10.1" + }, + "time": "2025-12-17T23:17:22+00:00" + }, { "name": "livewire/livewire", "version": "v3.7.1", diff --git a/lang/en/general.php b/lang/en/general.php index 9b8aa7d95..9c95a79f9 100644 --- a/lang/en/general.php +++ b/lang/en/general.php @@ -52,6 +52,9 @@ 'links' => 'Links', 'donate' => 'Donate', 'donations' => 'Donations', + 'light' => 'Light', + 'dark' => 'Dark', + 'system' => 'System', // Roles 'admin' => 'Admin', diff --git a/package-lock.json b/package-lock.json index 6e8087046..1e1769f73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,13 @@ { - "name": "html", + "name": "speedtest-tracker", "lockfileVersion": 3, "requires": true, "packages": { "": { + "dependencies": { + "@alpinejs/sort": "^3.15.3", + "chart.js": "^4.5.1" + }, "devDependencies": { "@tailwindcss/typography": "^0.5.10", "@tailwindcss/vite": "^4.1.17", @@ -13,6 +17,12 @@ "vite": "^6.4.1" } }, + "node_modules/@alpinejs/sort": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.15.3.tgz", + "integrity": "sha512-IZ4Dtl3EdkCBsoVsDtZNgyq+g5qr1dzVsyuXtxTv+OWjMXvgEfCfw4nz1eSPMKrK/nawMG6qFiw8Uv8J1ld//g==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -505,6 +515,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", @@ -1208,6 +1224,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", diff --git a/package.json b/package.json index 87e7212c3..9d0cc7bb3 100644 --- a/package.json +++ b/package.json @@ -12,5 +12,9 @@ "laravel-vite-plugin": "^1.0.0", "tailwindcss": "^4.1.17", "vite": "^6.4.1" + }, + "dependencies": { + "@alpinejs/sort": "^3.15.3", + "chart.js": "^4.5.1" } } diff --git a/resources/css/app.css b/resources/css/app.css index 532b718c1..0b8d4b0a7 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,31 +1,33 @@ @import 'tailwindcss'; -@import './custom.css'; - -/* Safelist max-width utilities to always generate them */ -@source inline("max-w-{xs,sm,md,lg,xl,2xl,3xl,4xl,5xl,6xl,7xl,full,min,max,fit,prose,screen-sm,screen-md,screen-lg,screen-xl,screen-2xl}"); - -/* Required by all components */ -@import '../../vendor/filament/support/resources/css/index.css'; - -/* Required by actions and tables */ -@import '../../vendor/filament/actions/resources/css/index.css'; - -/* Required by actions, forms and tables */ -@import '../../vendor/filament/forms/resources/css/index.css'; - -/* Required by actions and infolists */ -@import '../../vendor/filament/infolists/resources/css/index.css'; - -/* Required by notifications */ -/* @import '../../vendor/filament/notifications/resources/css/index.css'; */ - -/* Required by actions, infolists, forms, schemas and tables */ -@import '../../vendor/filament/schemas/resources/css/index.css'; - -/* Required by tables */ -/* @import '../../vendor/filament/tables/resources/css/index.css'; */ - -/* Required by widgets */ -@import '../../vendor/filament/widgets/resources/css/index.css'; - -@variant dark (&:where(.dark, .dark *)); +@import '../../vendor/livewire/flux/dist/flux.css'; + +@custom-variant dark (&:where(.dark, .dark *)); + +/* Re-assign Flux's gray of choice... */ +@theme { + --color-zinc-50: var(--color-neutral-50); + --color-zinc-100: var(--color-neutral-100); + --color-zinc-200: var(--color-neutral-200); + --color-zinc-300: var(--color-neutral-300); + --color-zinc-400: var(--color-neutral-400); + --color-zinc-500: var(--color-neutral-500); + --color-zinc-600: var(--color-neutral-600); + --color-zinc-700: var(--color-neutral-700); + --color-zinc-800: var(--color-neutral-800); + --color-zinc-900: var(--color-neutral-900); + --color-zinc-950: var(--color-neutral-950); +} + +@theme { + --color-accent: var(--color-amber-400); + --color-accent-content: var(--color-amber-600); + --color-accent-foreground: var(--color-amber-950); +} + +@layer theme { + .dark { + --color-accent: var(--color-amber-400); + --color-accent-content: var(--color-amber-400); + --color-accent-foreground: var(--color-amber-950); + } +} diff --git a/resources/css/dashboard.css b/resources/css/dashboard.css new file mode 100644 index 000000000..532b718c1 --- /dev/null +++ b/resources/css/dashboard.css @@ -0,0 +1,31 @@ +@import 'tailwindcss'; +@import './custom.css'; + +/* Safelist max-width utilities to always generate them */ +@source inline("max-w-{xs,sm,md,lg,xl,2xl,3xl,4xl,5xl,6xl,7xl,full,min,max,fit,prose,screen-sm,screen-md,screen-lg,screen-xl,screen-2xl}"); + +/* Required by all components */ +@import '../../vendor/filament/support/resources/css/index.css'; + +/* Required by actions and tables */ +@import '../../vendor/filament/actions/resources/css/index.css'; + +/* Required by actions, forms and tables */ +@import '../../vendor/filament/forms/resources/css/index.css'; + +/* Required by actions and infolists */ +@import '../../vendor/filament/infolists/resources/css/index.css'; + +/* Required by notifications */ +/* @import '../../vendor/filament/notifications/resources/css/index.css'; */ + +/* Required by actions, infolists, forms, schemas and tables */ +@import '../../vendor/filament/schemas/resources/css/index.css'; + +/* Required by tables */ +/* @import '../../vendor/filament/tables/resources/css/index.css'; */ + +/* Required by widgets */ +@import '../../vendor/filament/widgets/resources/css/index.css'; + +@variant dark (&:where(.dark, .dark *)); diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 000000000..d01b445d9 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1,39 @@ +import { Livewire, Alpine } from '../../vendor/livewire/livewire/dist/livewire.esm'; +import sort from '@alpinejs/sort'; + +import { + Chart, + LineController, + LineElement, + PointElement, + LinearScale, + CategoryScale, + BarController, + BarElement, + ScatterController, + Tooltip, + Legend, + Filler +} from 'chart.js'; + +Chart.register( + LineController, + LineElement, + PointElement, + LinearScale, + CategoryScale, + BarController, + BarElement, + ScatterController, + Tooltip, + Legend, + Filler +); + +window.Chart = Chart; + +document.addEventListener('livewire:init', () => { + window.Alpine.plugin(sort); +}); + +Livewire.start(); diff --git a/resources/views/components/dashboard/stats-card.blade.php b/resources/views/components/dashboard/stats-card.blade.php new file mode 100644 index 000000000..dca24baf0 --- /dev/null +++ b/resources/views/components/dashboard/stats-card.blade.php @@ -0,0 +1,7 @@ +
merge(['class' => 'px-6 py-3']) }}> + @isset($heading) + {{ $heading }} + @endisset + + {{ $slot }} +
diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php new file mode 100644 index 000000000..5e055c231 --- /dev/null +++ b/resources/views/components/layouts/app.blade.php @@ -0,0 +1,32 @@ + + + + + + + {{ $title ?? 'Page Title' }} - {{ config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + + @include('partials.header') + + + {{ $slot }} + + + @fluxScripts + @livewireScriptConfig + + diff --git a/resources/views/flux/icon/activity.blade.php b/resources/views/flux/icon/activity.blade.php new file mode 100644 index 000000000..ed4cb594c --- /dev/null +++ b/resources/views/flux/icon/activity.blade.php @@ -0,0 +1,43 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + diff --git a/resources/views/flux/icon/arrow-up-down.blade.php b/resources/views/flux/icon/arrow-up-down.blade.php new file mode 100644 index 000000000..b8e9050f0 --- /dev/null +++ b/resources/views/flux/icon/arrow-up-down.blade.php @@ -0,0 +1,46 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/flux/icon/calendar-search.blade.php b/resources/views/flux/icon/calendar-search.blade.php new file mode 100644 index 000000000..88380e0e5 --- /dev/null +++ b/resources/views/flux/icon/calendar-search.blade.php @@ -0,0 +1,48 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + diff --git a/resources/views/flux/icon/chart-line.blade.php b/resources/views/flux/icon/chart-line.blade.php new file mode 100644 index 000000000..32b88acfe --- /dev/null +++ b/resources/views/flux/icon/chart-line.blade.php @@ -0,0 +1,44 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/flux/icon/chart-no-axes-combined.blade.php b/resources/views/flux/icon/chart-no-axes-combined.blade.php new file mode 100644 index 000000000..42f2e2ea5 --- /dev/null +++ b/resources/views/flux/icon/chart-no-axes-combined.blade.php @@ -0,0 +1,48 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + diff --git a/resources/views/flux/icon/coffee.blade.php b/resources/views/flux/icon/coffee.blade.php new file mode 100644 index 000000000..d6664611a --- /dev/null +++ b/resources/views/flux/icon/coffee.blade.php @@ -0,0 +1,46 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/flux/icon/download.blade.php b/resources/views/flux/icon/download.blade.php new file mode 100644 index 000000000..6cebad23e --- /dev/null +++ b/resources/views/flux/icon/download.blade.php @@ -0,0 +1,45 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/file-exclamation-point.blade.php b/resources/views/flux/icon/file-exclamation-point.blade.php new file mode 100644 index 000000000..92b27d580 --- /dev/null +++ b/resources/views/flux/icon/file-exclamation-point.blade.php @@ -0,0 +1,45 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/grip-vertical.blade.php b/resources/views/flux/icon/grip-vertical.blade.php new file mode 100644 index 000000000..96d820c8c --- /dev/null +++ b/resources/views/flux/icon/grip-vertical.blade.php @@ -0,0 +1,48 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + diff --git a/resources/views/flux/icon/log-in.blade.php b/resources/views/flux/icon/log-in.blade.php new file mode 100644 index 000000000..61a4963fc --- /dev/null +++ b/resources/views/flux/icon/log-in.blade.php @@ -0,0 +1,45 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/monitor.blade.php b/resources/views/flux/icon/monitor.blade.php new file mode 100644 index 000000000..2c190a9fd --- /dev/null +++ b/resources/views/flux/icon/monitor.blade.php @@ -0,0 +1,45 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/moon.blade.php b/resources/views/flux/icon/moon.blade.php new file mode 100644 index 000000000..2230955e0 --- /dev/null +++ b/resources/views/flux/icon/moon.blade.php @@ -0,0 +1,43 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + diff --git a/resources/views/flux/icon/package.blade.php b/resources/views/flux/icon/package.blade.php new file mode 100644 index 000000000..3e8579d07 --- /dev/null +++ b/resources/views/flux/icon/package.blade.php @@ -0,0 +1,46 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/flux/icon/rabbit.blade.php b/resources/views/flux/icon/rabbit.blade.php new file mode 100644 index 000000000..4daa4dfc6 --- /dev/null +++ b/resources/views/flux/icon/rabbit.blade.php @@ -0,0 +1,47 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + diff --git a/resources/views/flux/icon/radio.blade.php b/resources/views/flux/icon/radio.blade.php new file mode 100644 index 000000000..4eda1fd87 --- /dev/null +++ b/resources/views/flux/icon/radio.blade.php @@ -0,0 +1,47 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + diff --git a/resources/views/flux/icon/rocket.blade.php b/resources/views/flux/icon/rocket.blade.php new file mode 100644 index 000000000..0a4b092b4 --- /dev/null +++ b/resources/views/flux/icon/rocket.blade.php @@ -0,0 +1,46 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/flux/icon/settings.blade.php b/resources/views/flux/icon/settings.blade.php new file mode 100644 index 000000000..4fbf08931 --- /dev/null +++ b/resources/views/flux/icon/settings.blade.php @@ -0,0 +1,44 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/flux/icon/sliders-vertical.blade.php b/resources/views/flux/icon/sliders-vertical.blade.php new file mode 100644 index 000000000..bb566a1a8 --- /dev/null +++ b/resources/views/flux/icon/sliders-vertical.blade.php @@ -0,0 +1,51 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + + + + diff --git a/resources/views/flux/icon/sun.blade.php b/resources/views/flux/icon/sun.blade.php new file mode 100644 index 000000000..936ba53f8 --- /dev/null +++ b/resources/views/flux/icon/sun.blade.php @@ -0,0 +1,51 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + + + + + diff --git a/resources/views/flux/icon/table.blade.php b/resources/views/flux/icon/table.blade.php new file mode 100644 index 000000000..8dab79c13 --- /dev/null +++ b/resources/views/flux/icon/table.blade.php @@ -0,0 +1,46 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + diff --git a/resources/views/flux/icon/triangle-alert.blade.php b/resources/views/flux/icon/triangle-alert.blade.php new file mode 100644 index 000000000..9100d4eac --- /dev/null +++ b/resources/views/flux/icon/triangle-alert.blade.php @@ -0,0 +1,45 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/upload.blade.php b/resources/views/flux/icon/upload.blade.php new file mode 100644 index 000000000..98540cd2c --- /dev/null +++ b/resources/views/flux/icon/upload.blade.php @@ -0,0 +1,45 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 078505014..e0da24fd3 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -10,14 +10,13 @@ {{-- Fonts --}} - {{-- Styles --}} - @vite('resources/css/app.css') + @vite('resources/css/dashboard.css') @filamentStyles +@endscript diff --git a/resources/views/partials/header.blade.php b/resources/views/partials/header.blade.php new file mode 100644 index 000000000..1a1859808 --- /dev/null +++ b/resources/views/partials/header.blade.php @@ -0,0 +1,102 @@ + + + + + + + + + + + + Dashboard + {{-- Results --}} + + + + + +
+ + + + + + + + + + {{ __('general.light') }} + + + + {{ __('general.dark') }} + + + + {{ __('general.system') }} + + + +
+ + {{-- TODO: Add speedtest modal here --}} + + + + @auth + Admin Panel + @else + Login + @endauth +
+
+ + + + + + + + Home + Inbox + Documents + Calendar + + Marketing site + Android app + Brand guidelines + + + + + Settings + Help + + diff --git a/routes/web.php b/routes/web.php index 19ca32a2a..10cef50bc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\HomeController; use App\Http\Controllers\MetricsController; use App\Http\Middleware\PrometheusAllowedIpMiddleware; +use App\Livewire\MetricsDashboard; use Illuminate\Support\Facades\Route; /* @@ -20,6 +21,9 @@ ->middleware(['getting-started', 'public-dashboard']) ->name('home'); +Route::get('/dashboard', MetricsDashboard::class) + ->name('dashboard'); + Route::get('/prometheus', MetricsController::class) ->middleware(PrometheusAllowedIpMiddleware::class) ->name('prometheus'); diff --git a/tests/Feature/MetricsDashboardPersistenceTest.php b/tests/Feature/MetricsDashboardPersistenceTest.php new file mode 100644 index 000000000..0d57c928b --- /dev/null +++ b/tests/Feature/MetricsDashboardPersistenceTest.php @@ -0,0 +1,51 @@ +subDay()->format('Y-m-d'); + $endDate = now()->format('Y-m-d'); + + expect($component->get('startDate'))->toBe($startDate); + expect($component->get('endDate'))->toBe($endDate); +}); + +it('does not include localStorage persistence code in rendered view', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertDontSee('localStorage.getItem(\'metrics-date-range\')', false); + $response->assertDontSee('localStorage.setItem(\'metrics-date-range\'', false); +}); + +it('includes display settings modal in rendered view', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('flux:modal.trigger', false); + $response->assertSee('name="displaySettingsModal"', false); + $response->assertSee('Manage Sections', false); + $response->assertSee('Uncheck to hide sections', false); +}); + +it('does not include sorting functionality in rendered view', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertDontSee('x-sort', false); + $response->assertDontSee('x-sort:item', false); + $response->assertDontSee('x-sort:handle', false); + $response->assertDontSee('grip-vertical', false); + $response->assertDontSee('handleSort', false); +}); + +it('includes max date constraint in date inputs', function () { + $response = $this->get(route('dashboard')); + $today = now()->format('Y-m-d'); + + $response->assertSuccessful(); + $response->assertSee('max="'.$today.'"', false); +}); diff --git a/tests/Feature/MetricsDashboardSectionManagementTest.php b/tests/Feature/MetricsDashboardSectionManagementTest.php new file mode 100644 index 000000000..89903612c --- /dev/null +++ b/tests/Feature/MetricsDashboardSectionManagementTest.php @@ -0,0 +1,69 @@ +get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('Speed', false); + $response->assertSee('Ping', false); + $response->assertSee('Latency (IQM)', false); + $response->assertSee('Jitter', false); +}); + +it('includes section management UI in filter modal', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('Manage Sections', false); + $response->assertSee('Drag to reorder', false); + $response->assertSee('Reset to Default Order', false); +}); + +it('includes Alpine sort directive in rendered view', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('x-sort', false); + $response->assertSee('sectionManager()', false); + $response->assertSee('dashboardSections()', false); +}); + +it('includes all four sections in sortable list', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee("id: 'speed'", false); + $response->assertSee("id: 'ping'", false); + $response->assertSee("id: 'latency'", false); + $response->assertSee("id: 'jitter'", false); +}); + +it('includes localStorage preference loading logic', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee("localStorage.getItem('metrics-dashboard-preferences')", false); +}); + +it('includes default preferences fallback', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee("sectionOrder: ['speed', 'ping', 'latency', 'jitter']", false); + $response->assertSee('hiddenSections: []', false); +}); + +it('dispatches event when preferences change', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee("window.dispatchEvent(new CustomEvent('dashboard-preferences-changed'", false); +}); + +it('includes validation for corrupted preferences', function () { + $response = $this->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('try {', false); + $response->assertSee('catch (e)', false); +}); diff --git a/tests/Unit/Livewire/MetricsDashboardTest.php b/tests/Unit/Livewire/MetricsDashboardTest.php new file mode 100644 index 000000000..114e3c2fb --- /dev/null +++ b/tests/Unit/Livewire/MetricsDashboardTest.php @@ -0,0 +1,914 @@ +count(5)->create([ + 'created_at' => now()->subHours(2), + 'download' => 125000000, // 125 MB/s = 1000 Mbps + 'upload' => 50000000, // 50 MB/s = 400 Mbps + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // Should return 5 individual results, not 1 aggregated day + expect($chartData['labels'])->toHaveCount(5); + expect($chartData['download'])->toHaveCount(5); + expect($chartData['upload'])->toHaveCount(5); +}); + +it('formats labels as time only for 1 day or less range', function () { + Result::factory()->create([ + 'created_at' => now()->setTime(14, 30, 0), + 'download' => 125000000, + 'upload' => 50000000, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->set('startDate', now()->subDay()->format('Y-m-d')); + $component->set('endDate', now()->format('Y-m-d')); + $chartData = $component->instance()->getChartData(); + + // Should match format like "2:30 PM" + expect($chartData['labels'][0])->toMatch('/\d{1,2}:\d{2} [AP]M/'); +}); + +it('formats labels as day and time for week range', function () { + Result::factory()->create([ + 'created_at' => now()->setTime(14, 30, 0), + 'download' => 125000000, + 'upload' => 50000000, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->set('startDate', now()->subWeek()->format('Y-m-d')); + $component->set('endDate', now()->format('Y-m-d')); + $chartData = $component->instance()->getChartData(); + + // Should match format like "Mon 2:30 PM" + expect($chartData['labels'][0])->toMatch('/[A-Za-z]{3} \d{1,2}:\d{2} [AP]M/'); +}); + +it('formats labels as date and time for month range', function () { + Result::factory()->create([ + 'created_at' => now()->setTime(14, 30, 0), + 'download' => 125000000, + 'upload' => 50000000, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->set('startDate', now()->subMonth()->format('Y-m-d')); + $component->set('endDate', now()->format('Y-m-d')); + $chartData = $component->instance()->getChartData(); + + // Should match format like "Dec 15 2:30 PM" + expect($chartData['labels'][0])->toMatch('/[A-Za-z]{3} \d{1,2} \d{1,2}:\d{2} [AP]M/'); +}); + +it('converts download speed from bytes per second to Mbps correctly', function () { + // Create result with known download speed + // 125,000,000 bytes/sec * 8 = 1,000,000,000 bits/sec = 1000 Mbps + Result::factory()->create([ + 'created_at' => now(), + 'download' => 125000000, + 'upload' => 50000000, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['download'][0])->toBe(1000.0); +}); + +it('converts upload speed from bytes per second to Mbps correctly', function () { + // Create result with known upload speed + // 50,000,000 bytes/sec * 8 = 400,000,000 bits/sec = 400 Mbps + Result::factory()->create([ + 'created_at' => now(), + 'download' => 125000000, + 'upload' => 50000000, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['upload'][0])->toBe(400.0); +}); + +it('handles null download values gracefully', function () { + Result::factory()->create([ + 'created_at' => now(), + 'download' => null, + 'upload' => 50000000, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['download'][0])->toBe(0.0); +}); + +it('handles null upload values gracefully', function () { + Result::factory()->create([ + 'created_at' => now(), + 'download' => 125000000, + 'upload' => null, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['upload'][0])->toBe(0.0); +}); + +it('returns empty arrays when no results exist', function () { + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['labels'])->toBeEmpty(); + expect($chartData['download'])->toBeEmpty(); + expect($chartData['upload'])->toBeEmpty(); + expect($chartData['count'])->toBe(0); +}); + +it('only includes completed results within date range', function () { + // Create completed result within range + Result::factory()->create([ + 'created_at' => now()->subHour(), + ]); + + // Create result outside range (should be excluded) + Result::factory()->create([ + 'created_at' => now()->subDays(2), + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['count'])->toBe(1); +}); + +it('returns correct count of results', function () { + Result::factory()->count(10)->create([ + 'created_at' => now()->subHours(2), + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['count'])->toBe(10); +}); + +it('orders results by created_at chronologically', function () { + // Create results in reverse order + $result1 = Result::factory()->create([ + 'created_at' => now()->subHours(3), + 'download' => 100000000, + ]); + + $result2 = Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'download' => 200000000, + ]); + + $result3 = Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'download' => 300000000, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // Data should be ordered chronologically (oldest to newest) + expect($chartData['download'])->toBe([800.0, 1600.0, 2400.0]); +}); + +it('calculates healthy ratio based on download benchmark KPI passed status', function () { + // Create 4 results: 3 passed, 1 failed download benchmark + Result::factory()->create([ + 'created_at' => now()->subHours(4), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(3), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // 3 out of 4 passed = 75% + expect($chartData['downloadStats']['healthy'])->toBe(75.0); +}); + +it('calculates healthy ratio based on upload benchmark KPI passed status', function () { + // Create 5 results: 2 passed, 3 failed upload benchmark + Result::factory()->create([ + 'created_at' => now()->subHours(5), + 'benchmarks' => [ + 'upload' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(4), + 'benchmarks' => [ + 'upload' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(3), + 'benchmarks' => [ + 'upload' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'benchmarks' => [ + 'upload' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'upload' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // 2 out of 5 passed = 40% + expect($chartData['uploadStats']['healthy'])->toBe(40.0); +}); + +it('calculates healthy ratio based on ping benchmark KPI passed status', function () { + // Create 10 results: all passed ping benchmark + Result::factory()->count(10)->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'ping' => ['bar' => 'max', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // 10 out of 10 passed = 100% + expect($chartData['pingStats']['healthy'])->toBe(100.0); +}); + +it('calculates separate healthy ratios for each metric', function () { + // Create results with different benchmark results per metric + Result::factory()->create([ + 'created_at' => now()->subHours(3), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + 'upload' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + 'ping' => ['bar' => 'max', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + 'upload' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + 'ping' => ['bar' => 'max', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // Download: 1 passed out of 2 = 50% + expect($chartData['downloadStats']['healthy'])->toBe(50.0); + // Upload: 0 passed out of 2 = 0% + expect($chartData['uploadStats']['healthy'])->toBe(0.0); + // Ping: 2 passed out of 2 = 100% + expect($chartData['pingStats']['healthy'])->toBe(100.0); +}); + +it('handles results without benchmarks in healthy ratio calculation', function () { + // Create results without benchmark data + Result::factory()->count(3)->create([ + 'created_at' => now()->subHours(2), + 'benchmarks' => null, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // No benchmarks means no passed tests = 0% + expect($chartData['downloadStats']['healthy'])->toBe(0.0); + expect($chartData['uploadStats']['healthy'])->toBe(0.0); + expect($chartData['pingStats']['healthy'])->toBe(0.0); +}); + +it('returns 0 healthy ratio when no results exist', function () { + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['downloadStats']['healthy'])->toBe(0.0); + expect($chartData['uploadStats']['healthy'])->toBe(0.0); + expect($chartData['pingStats']['healthy'])->toBe(0.0); +}); + +it('marks latest stat as failed when the most recent benchmark failed', function () { + // Create older result that passed + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + 'upload' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + 'ping' => ['bar' => 'max', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + // Create most recent result that failed benchmarks + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + 'upload' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + 'ping' => ['bar' => 'max', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['downloadStats']['latestFailed'])->toBeTrue(); + expect($chartData['uploadStats']['latestFailed'])->toBeTrue(); + expect($chartData['pingStats']['latestFailed'])->toBeTrue(); +}); + +it('marks latest stat as not failed when the most recent benchmark passed', function () { + // Create older result that failed + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + 'upload' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + 'ping' => ['bar' => 'max', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + // Create most recent result that passed benchmarks + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + 'upload' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + 'ping' => ['bar' => 'max', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['downloadStats']['latestFailed'])->toBeFalse(); + expect($chartData['uploadStats']['latestFailed'])->toBeFalse(); + expect($chartData['pingStats']['latestFailed'])->toBeFalse(); +}); + +it('filters only scheduled results when scheduled filter is set', function () { + // Create scheduled and unscheduled results + Result::factory()->count(3)->create([ + 'created_at' => now()->subHours(2), + 'scheduled' => true, + ]); + + Result::factory()->count(2)->create([ + 'created_at' => now()->subHours(2), + 'scheduled' => false, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->set('scheduledFilter', 'scheduled'); + $chartData = $component->instance()->getChartData(); + + expect($chartData['count'])->toBe(3); +}); + +it('filters only unscheduled results when unscheduled filter is set', function () { + // Create scheduled and unscheduled results + Result::factory()->count(3)->create([ + 'created_at' => now()->subHours(2), + 'scheduled' => true, + ]); + + Result::factory()->count(4)->create([ + 'created_at' => now()->subHours(2), + 'scheduled' => false, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->set('scheduledFilter', 'unscheduled'); + $chartData = $component->instance()->getChartData(); + + expect($chartData['count'])->toBe(4); +}); + +it('returns all results when scheduled filter is set to all', function () { + // Create scheduled and unscheduled results + Result::factory()->count(3)->create([ + 'created_at' => now()->subHours(2), + 'scheduled' => true, + ]); + + Result::factory()->count(2)->create([ + 'created_at' => now()->subHours(2), + 'scheduled' => false, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->set('scheduledFilter', 'all'); + $chartData = $component->instance()->getChartData(); + + expect($chartData['count'])->toBe(5); +}); + +it('sets date range to last day when setLastDay is called', function () { + $component = Livewire::test(MetricsDashboard::class); + $component->call('setLastDay'); + + expect($component->get('startDate'))->toBe(now()->subDay()->format('Y-m-d')); + expect($component->get('endDate'))->toBe(now()->format('Y-m-d')); +}); + +it('sets date range to last week when setLastWeek is called', function () { + $component = Livewire::test(MetricsDashboard::class); + $component->call('setLastWeek'); + + expect($component->get('startDate'))->toBe(now()->subWeek()->format('Y-m-d')); + expect($component->get('endDate'))->toBe(now()->format('Y-m-d')); +}); + +it('sets date range to last month when setLastMonth is called', function () { + $component = Livewire::test(MetricsDashboard::class); + $component->call('setLastMonth'); + + expect($component->get('startDate'))->toBe(now()->subMonth()->format('Y-m-d')); + expect($component->get('endDate'))->toBe(now()->format('Y-m-d')); +}); + +it('dispatches charts-updated event when setLastDay is called', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(12), + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->call('setLastDay'); + + $component->assertDispatched('charts-updated'); +}); + +it('dispatches charts-updated event when setLastWeek is called', function () { + Result::factory()->create([ + 'created_at' => now()->subDays(3), + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->call('setLastWeek'); + + $component->assertDispatched('charts-updated'); +}); + +it('dispatches charts-updated event when setLastMonth is called', function () { + Result::factory()->create([ + 'created_at' => now()->subDays(15), + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->call('setLastMonth'); + + $component->assertDispatched('charts-updated'); +}); + +it('uses DEFAULT_CHART_RANGE config for default start date', function () { + config(['speedtest.default_chart_range' => '7d']); + + $component = Livewire::test(MetricsDashboard::class); + + expect($component->get('startDate'))->toBe(now()->subDays(7)->format('Y-m-d')); + expect($component->get('endDate'))->toBe(now()->format('Y-m-d')); +}); + +it('parses hours from DEFAULT_CHART_RANGE config', function () { + config(['speedtest.default_chart_range' => '48h']); + + $component = Livewire::test(MetricsDashboard::class); + + expect($component->get('startDate'))->toBe(now()->subHours(48)->format('Y-m-d')); +}); + +it('parses weeks from DEFAULT_CHART_RANGE config', function () { + config(['speedtest.default_chart_range' => '2w']); + + $component = Livewire::test(MetricsDashboard::class); + + expect($component->get('startDate'))->toBe(now()->subWeeks(2)->format('Y-m-d')); +}); + +it('parses months from DEFAULT_CHART_RANGE config', function () { + config(['speedtest.default_chart_range' => '3m']); + + $component = Livewire::test(MetricsDashboard::class); + + expect($component->get('startDate'))->toBe(now()->subMonths(3)->format('Y-m-d')); +}); + +it('defaults to 1 day when DEFAULT_CHART_RANGE config is invalid', function () { + config(['speedtest.default_chart_range' => 'invalid']); + + $component = Livewire::test(MetricsDashboard::class); + + expect($component->get('startDate'))->toBe(now()->subDay()->format('Y-m-d')); +}); + +it('checks for new results and refreshes charts when new result is added', function () { + $initialResult = Result::factory()->create([ + 'created_at' => now()->subHours(2), + ]); + + $component = Livewire::test(MetricsDashboard::class); + + // Create a new result + $newResult = Result::factory()->create([ + 'created_at' => now()->subHours(1), + ]); + + // Call checkForNewResults + $component->call('checkForNewResults'); + + // Should dispatch charts-updated event + $component->assertDispatched('charts-updated'); +}); + +it('does not refresh charts when no new results exist', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(1), + ]); + + $component = Livewire::test(MetricsDashboard::class); + + // Call checkForNewResults without adding new results + $component->call('checkForNewResults'); + + // Should not dispatch charts-updated event (no new results) + $component->assertNotDispatched('charts-updated'); +}); + +it('updates lastResultId when new result is detected', function () { + $initialResult = Result::factory()->create([ + 'created_at' => now()->subHours(2), + ]); + + $component = Livewire::test(MetricsDashboard::class); + expect($component->get('lastResultId'))->toBe($initialResult->id); + + // Create a new result + $newResult = Result::factory()->create([ + 'created_at' => now()->subHours(1), + ]); + + // Call checkForNewResults + $component->call('checkForNewResults'); + + // lastResultId should be updated + expect($component->get('lastResultId'))->toBe($newResult->id); +}); + +it('has failed results when download benchmark fails', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['hasFailedResults'])->toBeTrue(); +}); + +it('has failed results when upload benchmark fails', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'upload' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['hasFailedResults'])->toBeTrue(); +}); + +it('has failed results when ping benchmark fails', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'ping' => ['bar' => 'max', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['hasFailedResults'])->toBeTrue(); +}); + +it('does not have failed results when all benchmarks pass', function () { + Result::factory()->count(3)->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + 'upload' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + 'ping' => ['bar' => 'max', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['hasFailedResults'])->toBeFalse(); +}); + +it('does not have failed results when no results exist', function () { + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['hasFailedResults'])->toBeFalse(); +}); + +it('has failed results when at least one result fails even if others pass', function () { + // Create passing results + Result::factory()->count(2)->create([ + 'created_at' => now()->subHours(3), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + 'upload' => ['bar' => 'min', 'passed' => true, 'type' => 'absolute', 'value' => 50, 'unit' => 'mbps'], + 'ping' => ['bar' => 'max', 'passed' => true, 'type' => 'absolute', 'value' => 100, 'unit' => 'ms'], + ], + ]); + + // Create one failing result + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['hasFailedResults'])->toBeTrue(); +}); + +it('includes results with failed status in chart data', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'status' => ResultStatus::Completed, + 'download' => 125000000, + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'status' => ResultStatus::Failed, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['count'])->toBe(2); + expect($chartData['resultStatusFailed'])->toHaveCount(2); +}); + +it('sets download and upload to 0 for failed status results', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'status' => ResultStatus::Completed, + 'download' => 125000000, + 'upload' => 50000000, + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'status' => ResultStatus::Failed, + 'download' => 125000000, + 'upload' => 50000000, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // First result (completed) should have normal values + expect($chartData['download'][0])->toBe(1000.0); + expect($chartData['upload'][0])->toBe(400.0); + + // Second result (failed) should be 0 + expect($chartData['download'][1])->toBe(0.0); + expect($chartData['upload'][1])->toBe(0.0); +}); + +it('tracks failed status results separately from benchmark failures', function () { + // Completed result that failed benchmark + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'status' => ResultStatus::Completed, + 'benchmarks' => [ + 'download' => ['bar' => 'min', 'passed' => false, 'type' => 'absolute', 'value' => 100, 'unit' => 'mbps'], + ], + ]); + + // Failed status result + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'status' => ResultStatus::Failed, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + expect($chartData['resultStatusFailed'][0])->toBeFalse(); + expect($chartData['resultStatusFailed'][1])->toBeTrue(); + expect($chartData['downloadBenchmarkFailed'][0])->toBeTrue(); +}); + +it('sets ping to 0 for failed status results', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'status' => ResultStatus::Completed, + 'ping' => 25.5, + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'status' => ResultStatus::Failed, + 'ping' => 50.0, + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // First result (completed) should have normal ping value + expect($chartData['ping'][0])->toBe(25.5); + + // Second result (failed) should be 0 + expect($chartData['ping'][1])->toBe(0.0); +}); + +it('sets latency to 0 for failed status results', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'status' => ResultStatus::Completed, + 'data' => [ + 'download' => ['latency' => ['iqm' => 12.5]], + 'upload' => ['latency' => ['iqm' => 18.3]], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'status' => ResultStatus::Failed, + 'data' => [ + 'download' => ['latency' => ['iqm' => 30.0]], + 'upload' => ['latency' => ['iqm' => 40.0]], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // First result (completed) should have normal latency values + expect($chartData['downloadLatency'][0])->toBe(12.5); + expect($chartData['uploadLatency'][0])->toBe(18.3); + + // Second result (failed) should be 0 + expect($chartData['downloadLatency'][1])->toBe(0.0); + expect($chartData['uploadLatency'][1])->toBe(0.0); +}); + +it('sets jitter to 0 for failed status results', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'status' => ResultStatus::Completed, + 'data' => [ + 'download' => ['jitter' => 2.5], + 'upload' => ['jitter' => 3.2], + 'ping' => ['jitter' => 1.8], + ], + ]); + + Result::factory()->create([ + 'created_at' => now()->subHours(1), + 'status' => ResultStatus::Failed, + 'data' => [ + 'download' => ['jitter' => 5.0], + 'upload' => ['jitter' => 6.0], + 'ping' => ['jitter' => 4.0], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $chartData = $component->instance()->getChartData(); + + // First result (completed) should have normal jitter values + expect($chartData['downloadJitter'][0])->toBe(2.5); + expect($chartData['uploadJitter'][0])->toBe(3.2); + expect($chartData['pingJitter'][0])->toBe(1.8); + + // Second result (failed) should be 0 + expect($chartData['downloadJitter'][1])->toBe(0.0); + expect($chartData['uploadJitter'][1])->toBe(0.0); + expect($chartData['pingJitter'][1])->toBe(0.0); +}); + +it('includes all jitter datasets when date range is updated', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(2), + 'data' => [ + 'download' => ['jitter' => 2.5], + 'upload' => ['jitter' => 3.2], + 'ping' => ['jitter' => 1.8], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + + // Update the date range + $component->set('startDate', now()->subDay()->format('Y-m-d')); + + // Verify the dispatched event includes all three jitter datasets + $component->assertDispatched('charts-updated', function ($event) { + $chartData = $event['chartData']; + + return isset($chartData['downloadJitter']) + && isset($chartData['uploadJitter']) + && isset($chartData['pingJitter']) + && is_array($chartData['downloadJitter']) + && is_array($chartData['uploadJitter']) + && is_array($chartData['pingJitter']); + }); +}); + +it('includes all jitter datasets when setLastDay is called', function () { + Result::factory()->create([ + 'created_at' => now()->subHours(12), + 'data' => [ + 'download' => ['jitter' => 1.5], + 'upload' => ['jitter' => 2.0], + 'ping' => ['jitter' => 1.2], + ], + ]); + + $component = Livewire::test(MetricsDashboard::class); + $component->call('setLastDay'); + + // Verify the dispatched event includes all three jitter datasets + $component->assertDispatched('charts-updated', function ($event) { + $chartData = $event['chartData']; + + return isset($chartData['downloadJitter']) + && isset($chartData['uploadJitter']) + && isset($chartData['pingJitter']) + && count($chartData['downloadJitter']) === 1 + && count($chartData['uploadJitter']) === 1 + && count($chartData['pingJitter']) === 1 + && $chartData['downloadJitter'][0] === 1.5 + && $chartData['uploadJitter'][0] === 2.0 + && $chartData['pingJitter'][0] === 1.2; + }); +}); diff --git a/tests/Unit/Models/ResultTest.php b/tests/Unit/Models/ResultTest.php new file mode 100644 index 000000000..90477f2a9 --- /dev/null +++ b/tests/Unit/Models/ResultTest.php @@ -0,0 +1,16 @@ +create(['status' => ResultStatus::Completed]); + Result::factory()->create(['status' => ResultStatus::Completed]); + Result::factory()->create(['status' => ResultStatus::Failed]); + Result::factory()->create(['status' => ResultStatus::Running]); + + $completedResults = Result::completed()->get(); + + expect($completedResults)->toHaveCount(2); + expect($completedResults->every(fn ($result) => $result->status === ResultStatus::Completed))->toBeTrue(); +}); diff --git a/vite.config.js b/vite.config.js index d4b0e527b..1bfb5df38 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,7 +7,7 @@ import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/css/filament/admin/theme.css'], + input: ['resources/css/app.css', 'resources/js/app.js', 'resources/css/dashboard.css', 'resources/css/filament/admin/theme.css'], refresh: [`resources/views/**/*`], }), tailwindcss(),