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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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
+
+
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(),