mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update admin dashboard
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AdminDashboardSnapshotService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class WarmAdminDashboard extends Command
|
||||
{
|
||||
protected $signature = 'admin:warm-dashboard';
|
||||
|
||||
protected $description = 'Rebuild and re-cache the admin dashboard snapshot (Cache::flexible payload)';
|
||||
|
||||
public function handle(AdminDashboardSnapshotService $snapshot): int
|
||||
{
|
||||
$start = microtime(true);
|
||||
$snapshot->warm();
|
||||
$elapsed = (int) ((microtime(true) - $start) * 1000);
|
||||
|
||||
$this->info("Admin dashboard snapshot warmed in {$elapsed} ms.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Models\DnzbFailure;
|
||||
use App\Models\Release;
|
||||
use App\Models\ReleaseReport;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Models\User;
|
||||
use App\Models\UserActivity;
|
||||
use App\Services\AdminDashboardSnapshotService;
|
||||
use App\Services\RegistrationStatusService;
|
||||
use App\Services\SiteStatusService;
|
||||
use App\Services\SystemMetricsService;
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AdminPageController extends BasePageController
|
||||
{
|
||||
@@ -28,17 +22,21 @@ class AdminPageController extends BasePageController
|
||||
|
||||
protected SiteStatusService $siteStatusService;
|
||||
|
||||
protected AdminDashboardSnapshotService $snapshot;
|
||||
|
||||
public function __construct(
|
||||
UserStatsService $userStatsService,
|
||||
SystemMetricsService $systemMetricsService,
|
||||
RegistrationStatusService $registrationStatusService,
|
||||
SiteStatusService $siteStatusService
|
||||
SiteStatusService $siteStatusService,
|
||||
AdminDashboardSnapshotService $snapshot,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->userStatsService = $userStatsService;
|
||||
$this->systemMetricsService = $systemMetricsService;
|
||||
$this->registrationStatusService = $registrationStatusService;
|
||||
$this->siteStatusService = $siteStatusService;
|
||||
$this->snapshot = $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,170 +45,90 @@ class AdminPageController extends BasePageController
|
||||
public function index(): mixed
|
||||
{
|
||||
$this->setAdminPrefs();
|
||||
$now = now();
|
||||
|
||||
$userStats = Cache::remember('admin_user_stats', 120, function () {
|
||||
return [
|
||||
'users_by_role' => $this->userStatsService->getUsersByRole(),
|
||||
'downloads_per_hour' => $this->userStatsService->getDownloadsPerHour(168),
|
||||
'downloads_per_minute' => $this->userStatsService->getDownloadsPerMinute(60),
|
||||
'api_hits_per_hour' => $this->userStatsService->getApiHitsPerHour(168),
|
||||
'api_hits_per_minute' => $this->userStatsService->getApiHitsPerMinute(60),
|
||||
'summary' => $this->userStatsService->getSummaryStats(),
|
||||
'top_downloaders' => $this->userStatsService->getTopDownloaders(5),
|
||||
];
|
||||
});
|
||||
|
||||
$registrationStatus = $this->registrationStatusService->resolve($now);
|
||||
$nextRegistrationPeriod = $registrationStatus['active_period'] === null
|
||||
? $this->registrationStatusService->getNextUpcomingPeriod($now)
|
||||
: null;
|
||||
$payload = $this->snapshot->get();
|
||||
|
||||
return view('admin.dashboard', array_merge($this->viewData, [
|
||||
'meta_title' => 'Admin Home',
|
||||
'meta_description' => 'Admin home page',
|
||||
'userStats' => $userStats,
|
||||
'stats' => $this->getDefaultStats(),
|
||||
'systemMetrics' => $this->getSystemMetrics(),
|
||||
'registrationStatus' => $registrationStatus,
|
||||
'nextRegistrationPeriod' => $nextRegistrationPeriod,
|
||||
'recent_activity' => $this->getRecentUserActivity(),
|
||||
'serviceStatuses' => $this->siteStatusService->getEnabledServices(),
|
||||
'activeIncidents' => $this->siteStatusService->getActiveIncidents(),
|
||||
// Lightweight data is rendered server-side; heavy widgets fetch on mount.
|
||||
'stats' => $payload['stats'],
|
||||
'registrationStatus' => $payload['registrationStatus'],
|
||||
'nextRegistrationPeriod' => $payload['nextRegistrationPeriod'],
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* JSON payload consumed by the dashboard's deferred widgets (User Statistics,
|
||||
* System Resources history, Recent Activity, Site Status). Reads from the
|
||||
* same snapshot cache the index uses, so the warmer keeps it hot.
|
||||
*/
|
||||
protected function getRecentUserActivity(): array
|
||||
public function getDashboardData(): JsonResponse
|
||||
{
|
||||
return Cache::remember('admin_recent_activity', 60, function () {
|
||||
$activities = UserActivity::orderBy('created_at', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
$payload = $this->snapshot->get();
|
||||
|
||||
return $activities->map(function ($activity) {
|
||||
return (object) [
|
||||
'type' => $activity->activity_type,
|
||||
'message' => $activity->description,
|
||||
'icon' => $activity->icon,
|
||||
'icon_bg' => 'bg-'.$activity->color_class.'-100 dark:bg-'.$activity->color_class.'-900',
|
||||
'icon_color' => 'text-'.$activity->color_class.'-600 dark:text-'.$activity->color_class.'-400',
|
||||
'created_at' => $activity->created_at,
|
||||
'username' => $activity->username,
|
||||
'metadata' => $activity->metadata,
|
||||
];
|
||||
})->toArray();
|
||||
});
|
||||
}
|
||||
$serviceStatuses = collect($payload['serviceStatuses'])->map(static fn ($svc) => [
|
||||
'name' => $svc->name,
|
||||
'slug' => $svc->slug,
|
||||
'check_type' => $svc->check_type,
|
||||
'status' => $svc->status->value,
|
||||
'status_label' => $svc->status->label(),
|
||||
'uptime_percentage' => (float) $svc->uptime_percentage,
|
||||
])->values();
|
||||
|
||||
public function getRecentActivity(): JsonResponse
|
||||
{
|
||||
$activities = $this->getRecentUserActivity();
|
||||
$activeIncidents = collect($payload['activeIncidents'])->map(static fn ($incident) => [
|
||||
'id' => $incident->id,
|
||||
'title' => $incident->title,
|
||||
'impact' => $incident->impact->value,
|
||||
'impact_label' => $incident->impact->label(),
|
||||
'started_at' => $incident->started_at?->toIso8601String(),
|
||||
'started_at_human' => $incident->started_at?->diffForHumans(),
|
||||
'is_auto' => (bool) $incident->is_auto,
|
||||
'services' => $incident->services->pluck('name')->all(),
|
||||
])->values();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'activities' => array_map(function ($activity) {
|
||||
return [
|
||||
$recentActivity = array_map(static fn ($activity): array => [
|
||||
'type' => $activity->type,
|
||||
'message' => $activity->message,
|
||||
'icon' => $activity->icon,
|
||||
'icon_bg' => $activity->icon_bg,
|
||||
'icon_color' => $activity->icon_color,
|
||||
'created_at' => $activity->created_at->diffForHumans(),
|
||||
'created_at' => $activity->created_at?->toIso8601String(),
|
||||
'created_at_human' => $activity->created_at?->diffForHumans(),
|
||||
'username' => $activity->username,
|
||||
'metadata' => $activity->metadata,
|
||||
];
|
||||
}, $activities),
|
||||
], $payload['recent_activity']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'generated_at' => $payload['generated_at'],
|
||||
'userStats' => $payload['userStats'],
|
||||
'systemMetrics' => $payload['systemMetrics'],
|
||||
'recent_activity' => $recentActivity,
|
||||
'serviceStatuses' => $serviceStatuses,
|
||||
'activeIncidents' => $activeIncidents,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getDefaultStats(): array
|
||||
public function getRecentActivity(): JsonResponse
|
||||
{
|
||||
$today = now()->format('Y-m-d');
|
||||
$payload = $this->snapshot->get();
|
||||
|
||||
$releasesCount = Cache::remember('admin_stats_releases_count', 300, fn () => Release::count());
|
||||
$activities = array_map(static fn ($activity): array => [
|
||||
'type' => $activity->type,
|
||||
'message' => $activity->message,
|
||||
'icon' => $activity->icon,
|
||||
'icon_bg' => $activity->icon_bg,
|
||||
'icon_color' => $activity->icon_color,
|
||||
'created_at' => $activity->created_at?->diffForHumans(),
|
||||
'username' => $activity->username,
|
||||
'metadata' => $activity->metadata,
|
||||
], $payload['recent_activity']);
|
||||
|
||||
$usersCount = Cache::remember('admin_stats_users_count', 300, fn () => User::whereNull('deleted_at')->count());
|
||||
|
||||
$groupsCount = Cache::remember('admin_stats_groups_count', 600, fn () => UsenetGroup::count());
|
||||
|
||||
$activeGroupsCount = Cache::remember('admin_stats_active_groups_count', 600, fn () => UsenetGroup::where('active', 1)->count());
|
||||
|
||||
$failedCount = Cache::remember('admin_stats_failed_count', 300, fn () => DnzbFailure::count());
|
||||
|
||||
$reportedCount = Cache::remember('admin_stats_reported_count', 300, fn () => ReleaseReport::where('status', 'pending')->count());
|
||||
|
||||
$softDeletedCount = Cache::remember('admin_stats_soft_deleted_users_count', 300, fn () => User::onlyTrashed()->count());
|
||||
|
||||
$permanentlyDeletedCount = Cache::remember('admin_stats_permanently_deleted_users_count', 300, fn () => UserActivity::where('activity_type', 'deleted')
|
||||
->whereJsonContains('metadata->permanent', true)
|
||||
->count());
|
||||
|
||||
$releasesToday = Cache::remember('admin_stats_releases_today_'.$today, 60, fn () => Release::whereDate('adddate', $today)->count());
|
||||
|
||||
$usersToday = Cache::remember('admin_stats_users_today_'.$today, 60, fn () => User::whereNull('deleted_at')->whereDate('created_at', $today)->count());
|
||||
|
||||
return [
|
||||
'releases' => $releasesCount,
|
||||
'releases_today' => $releasesToday,
|
||||
'users' => $usersCount,
|
||||
'users_today' => $usersToday,
|
||||
'groups' => $groupsCount,
|
||||
'active_groups' => $activeGroupsCount,
|
||||
'failed' => $failedCount,
|
||||
'reported' => $reportedCount,
|
||||
'soft_deleted_users' => $softDeletedCount,
|
||||
'permanently_deleted_users' => $permanentlyDeletedCount,
|
||||
'disk_free' => $this->systemMetricsService->getDiskSpace(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getSystemMetrics(): array
|
||||
{
|
||||
$cpuInfo = Cache::remember('admin_cpu_info', 3600, fn () => $this->systemMetricsService->getCpuInfo());
|
||||
|
||||
$cpuUsage = Cache::remember('admin_cpu_usage', 30, fn () => $this->systemMetricsService->getCpuUsage());
|
||||
|
||||
$ramUsage = Cache::remember('admin_ram_usage', 30, fn () => $this->systemMetricsService->getRamUsage());
|
||||
|
||||
$loadAverage = Cache::remember('admin_load_average', 30, fn () => $this->systemMetricsService->getLoadAverage());
|
||||
|
||||
$cpuHistory24h = Cache::remember('admin_cpu_history_24h', 300, fn () => $this->systemMetricsService->getHourlyMetrics('cpu', 24));
|
||||
|
||||
$cpuHistory30d = Cache::remember('admin_cpu_history_30d', 300, fn () => $this->systemMetricsService->getDailyMetrics('cpu', 30));
|
||||
|
||||
$ramHistory24h = Cache::remember('admin_ram_history_24h', 300, fn () => $this->systemMetricsService->getHourlyMetrics('ram', 24));
|
||||
|
||||
$ramHistory30d = Cache::remember('admin_ram_history_30d', 300, fn () => $this->systemMetricsService->getDailyMetrics('ram', 30));
|
||||
|
||||
return [
|
||||
'cpu' => [
|
||||
'current' => $cpuUsage,
|
||||
'label' => 'CPU Usage',
|
||||
'history_24h' => $cpuHistory24h,
|
||||
'history_30d' => $cpuHistory30d,
|
||||
'cores' => $cpuInfo['cores'],
|
||||
'threads' => $cpuInfo['threads'],
|
||||
'model' => $cpuInfo['model'],
|
||||
'load_average' => $loadAverage,
|
||||
],
|
||||
'ram' => [
|
||||
'used' => $ramUsage['used'],
|
||||
'total' => $ramUsage['total'],
|
||||
'percentage' => $ramUsage['percentage'],
|
||||
'label' => 'RAM Usage',
|
||||
'history_24h' => $ramHistory24h,
|
||||
'history_30d' => $ramHistory30d,
|
||||
],
|
||||
];
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'activities' => $activities,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getUserActivityMinutes(): JsonResponse
|
||||
@@ -268,3 +186,4 @@ class AdminPageController extends BasePageController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,13 @@ class UserActivity extends Model
|
||||
'activity_type',
|
||||
'description',
|
||||
'metadata',
|
||||
'is_permanent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'is_permanent' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -145,6 +145,7 @@ class UserActivityObserver
|
||||
'username' => $user->username,
|
||||
'activity_type' => 'deleted',
|
||||
'description' => "User permanently deleted: {$user->username}",
|
||||
'is_permanent' => true,
|
||||
'metadata' => [
|
||||
'email' => $user->email,
|
||||
'deleted_by' => $deletedBy,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\DnzbFailure;
|
||||
use App\Models\Release;
|
||||
use App\Models\ReleaseReport;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Models\User;
|
||||
use App\Models\UserActivity;
|
||||
use App\Support\ApproximateRowCount;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Builds (and caches) the entire admin dashboard payload in a single
|
||||
* Cache::flexible() entry so the controller never blocks on cold queries.
|
||||
*
|
||||
* - Fresh window: 60s
|
||||
* - Stale-while-revalidate window: 600s
|
||||
* - Warmed every minute by the `admin:warm-dashboard` scheduled command.
|
||||
*/
|
||||
class AdminDashboardSnapshotService
|
||||
{
|
||||
public const CACHE_KEY = 'admin:dashboard:snapshot';
|
||||
|
||||
/**
|
||||
* @var array{0: int, 1: int}
|
||||
*/
|
||||
private const CACHE_TTL = [60, 600];
|
||||
|
||||
public function __construct(
|
||||
private readonly UserStatsService $userStatsService,
|
||||
private readonly SystemMetricsService $systemMetricsService,
|
||||
private readonly RegistrationStatusService $registrationStatusService,
|
||||
private readonly SiteStatusService $siteStatusService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the cached dashboard snapshot, building it on first miss.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function get(): array
|
||||
{
|
||||
return Cache::flexible(self::CACHE_KEY, self::CACHE_TTL, fn (): array => $this->build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Forcibly rebuild and re-cache the snapshot. Used by the warmer command
|
||||
* and by admin actions that want immediate freshness.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function warm(): array
|
||||
{
|
||||
$payload = $this->build();
|
||||
// Re-prime both the value and the SWR control key used by Cache::flexible().
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
|
||||
return Cache::flexible(self::CACHE_KEY, self::CACHE_TTL, static fn (): array => $payload);
|
||||
}
|
||||
|
||||
public function forget(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build(): array
|
||||
{
|
||||
$now = now();
|
||||
$registrationStatus = $this->registrationStatusService->resolve($now);
|
||||
$nextRegistrationPeriod = $registrationStatus['active_period'] === null
|
||||
? $this->registrationStatusService->getNextUpcomingPeriod($now)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'generated_at' => $now->toIso8601String(),
|
||||
'stats' => $this->buildStats(),
|
||||
'userStats' => $this->buildUserStats(),
|
||||
'systemMetrics' => $this->buildSystemMetrics(),
|
||||
'recent_activity' => $this->buildRecentActivity(),
|
||||
'serviceStatuses' => $this->siteStatusService->getEnabledServices(),
|
||||
'activeIncidents' => $this->siteStatusService->getActiveIncidents(),
|
||||
'registrationStatus' => $registrationStatus,
|
||||
'nextRegistrationPeriod' => $nextRegistrationPeriod,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildStats(): array
|
||||
{
|
||||
$today = now()->format('Y-m-d');
|
||||
|
||||
// Approximate counts on huge tables — InnoDB metadata estimates are
|
||||
// good enough for the admin overview tiles.
|
||||
$releasesCount = ApproximateRowCount::for('releases');
|
||||
$usersCount = ApproximateRowCount::for('users');
|
||||
$groupsCount = ApproximateRowCount::for('usenet_groups');
|
||||
$failedCount = ApproximateRowCount::for('dnzb_failures');
|
||||
|
||||
// Small / index-friendly aggregates kept exact.
|
||||
$activeGroupsCount = UsenetGroup::where('active', 1)->count();
|
||||
$reportedCount = ReleaseReport::where('status', 'pending')->count();
|
||||
$softDeletedCount = User::onlyTrashed()->count();
|
||||
|
||||
// Replaces previous whereJsonContains() count which couldn't use an index.
|
||||
$permanentlyDeletedCount = UserActivity::query()
|
||||
->where('activity_type', 'deleted')
|
||||
->where('is_permanent', true)
|
||||
->count();
|
||||
|
||||
$releasesToday = Release::whereDate('adddate', $today)->count();
|
||||
$usersToday = User::whereNull('deleted_at')->whereDate('created_at', $today)->count();
|
||||
|
||||
return [
|
||||
'releases' => $releasesCount,
|
||||
'releases_today' => $releasesToday,
|
||||
'users' => $usersCount,
|
||||
'users_today' => $usersToday,
|
||||
'groups' => $groupsCount,
|
||||
'active_groups' => $activeGroupsCount,
|
||||
'failed' => $failedCount,
|
||||
'reported' => $reportedCount,
|
||||
'soft_deleted_users' => $softDeletedCount,
|
||||
'permanently_deleted_users' => $permanentlyDeletedCount,
|
||||
'disk_free' => $this->systemMetricsService->getDiskSpace(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildUserStats(): array
|
||||
{
|
||||
return [
|
||||
'users_by_role' => $this->userStatsService->getUsersByRole(),
|
||||
'downloads_per_hour' => $this->userStatsService->getDownloadsPerHour(168),
|
||||
'downloads_per_minute' => $this->userStatsService->getDownloadsPerMinute(60),
|
||||
'api_hits_per_hour' => $this->userStatsService->getApiHitsPerHour(168),
|
||||
'api_hits_per_minute' => $this->userStatsService->getApiHitsPerMinute(60),
|
||||
'summary' => $this->userStatsService->getSummaryStats(),
|
||||
'top_downloaders' => $this->userStatsService->getTopDownloaders(5),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSystemMetrics(): array
|
||||
{
|
||||
$cpuInfo = $this->systemMetricsService->getCpuInfo();
|
||||
$cpuUsage = $this->systemMetricsService->getCpuUsage();
|
||||
$ramUsage = $this->systemMetricsService->getRamUsage();
|
||||
$loadAverage = $this->systemMetricsService->getLoadAverage();
|
||||
|
||||
return [
|
||||
'cpu' => [
|
||||
'current' => $cpuUsage,
|
||||
'label' => 'CPU Usage',
|
||||
'history_24h' => $this->systemMetricsService->getHourlyMetrics('cpu', 24),
|
||||
'history_30d' => $this->systemMetricsService->getDailyMetrics('cpu', 30),
|
||||
'cores' => $cpuInfo['cores'],
|
||||
'threads' => $cpuInfo['threads'],
|
||||
'model' => $cpuInfo['model'],
|
||||
'load_average' => $loadAverage,
|
||||
],
|
||||
'ram' => [
|
||||
'used' => $ramUsage['used'],
|
||||
'total' => $ramUsage['total'],
|
||||
'percentage' => $ramUsage['percentage'],
|
||||
'label' => 'RAM Usage',
|
||||
'history_24h' => $this->systemMetricsService->getHourlyMetrics('ram', 24),
|
||||
'history_30d' => $this->systemMetricsService->getDailyMetrics('ram', 30),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
private function buildRecentActivity(): array
|
||||
{
|
||||
$activities = UserActivity::orderBy('created_at', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $activities->map(static function ($activity): object {
|
||||
return (object) [
|
||||
'type' => $activity->activity_type,
|
||||
'message' => $activity->description,
|
||||
'icon' => $activity->icon,
|
||||
'icon_bg' => 'bg-'.$activity->color_class.'-100 dark:bg-'.$activity->color_class.'-900',
|
||||
'icon_color' => 'text-'.$activity->color_class.'-600 dark:text-'.$activity->color_class.'-400',
|
||||
'created_at' => $activity->created_at,
|
||||
'username' => $activity->username,
|
||||
'metadata' => $activity->metadata,
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,26 @@ use App\Services\StatusProbes\ProbeResult;
|
||||
use App\Services\StatusProbes\ServiceProbeRegistry;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SiteStatusService
|
||||
{
|
||||
public const CACHE_KEY_ENABLED_SERVICES = 'admin:site-status:enabled-services';
|
||||
|
||||
public const CACHE_KEY_ACTIVE_INCIDENTS = 'admin:site-status:active-incidents';
|
||||
|
||||
/**
|
||||
* Cache freshness/stale window in seconds for the dashboard-facing reads.
|
||||
* 60s fresh + 600s stale-while-revalidate keeps reads hot without
|
||||
* blocking the request when health-check writes invalidate the keys.
|
||||
*
|
||||
* @var array{0: int, 1: int}
|
||||
*/
|
||||
private const CACHE_TTL = [60, 600];
|
||||
|
||||
public function __construct(
|
||||
private readonly ServiceProbeRegistry $probeRegistry,
|
||||
) {}
|
||||
@@ -37,11 +51,11 @@ class SiteStatusService
|
||||
*/
|
||||
public function getEnabledServices(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return ServiceStatus::query()
|
||||
return Cache::flexible(self::CACHE_KEY_ENABLED_SERVICES, self::CACHE_TTL, fn () => ServiceStatus::query()
|
||||
->where('is_enabled', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
->get());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,11 +120,22 @@ class SiteStatusService
|
||||
*/
|
||||
public function getActiveIncidents(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return ServiceIncident::query()
|
||||
->with('services')
|
||||
return Cache::flexible(self::CACHE_KEY_ACTIVE_INCIDENTS, self::CACHE_TTL, fn () => ServiceIncident::query()
|
||||
->with(['services:id,name,slug,check_type'])
|
||||
->where('status', '!=', IncidentStatusEnum::Resolved->value)
|
||||
->orderByDesc('started_at')
|
||||
->get();
|
||||
->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget cached service-status / incident reads. Call this from any code
|
||||
* path that mutates ServiceStatus or ServiceIncident rows.
|
||||
*/
|
||||
public function forgetDashboardCaches(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY_ENABLED_SERVICES);
|
||||
Cache::forget(self::CACHE_KEY_ACTIVE_INCIDENTS);
|
||||
Cache::forget(\App\Services\AdminDashboardSnapshotService::CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,6 +263,8 @@ class SiteStatusService
|
||||
|
||||
$this->recomputeServiceHealth($service);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,6 +326,8 @@ class SiteStatusService
|
||||
$this->recomputeServiceHealth($service);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return $incident->fresh(['services']);
|
||||
}
|
||||
|
||||
@@ -332,6 +361,8 @@ class SiteStatusService
|
||||
$this->recomputeServiceHealth($service);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return $incident->fresh(['services']);
|
||||
}
|
||||
|
||||
@@ -348,6 +379,8 @@ class SiteStatusService
|
||||
$this->recomputeServiceHealth($service);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return $incident->fresh(['services']);
|
||||
}
|
||||
|
||||
@@ -357,6 +390,8 @@ class SiteStatusService
|
||||
'status' => $status,
|
||||
'last_checked_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
}
|
||||
|
||||
public function recomputeServiceHealth(ServiceStatus $service): void
|
||||
@@ -370,6 +405,7 @@ class SiteStatusService
|
||||
|
||||
if ($active->isEmpty()) {
|
||||
$service->update(['status' => ServiceStatusEnum::Operational]);
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -383,6 +419,7 @@ class SiteStatusService
|
||||
}
|
||||
|
||||
$service->update(['status' => $worst]);
|
||||
$this->forgetDashboardCaches();
|
||||
}
|
||||
|
||||
private const int SLOW_THRESHOLD_MS = 5000;
|
||||
@@ -541,10 +578,14 @@ class SiteStatusService
|
||||
$this->sendIncidentEmail($existing->fresh(['services']), resolved: true);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existing !== null) {
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -564,6 +605,7 @@ class SiteStatusService
|
||||
|
||||
$this->recomputeServiceHealth($service);
|
||||
$this->sendIncidentEmail($incident, resolved: false);
|
||||
$this->forgetDashboardCaches();
|
||||
}
|
||||
|
||||
private function sendIncidentEmail(ServiceIncident $incident, bool $resolved): void
|
||||
|
||||
@@ -129,17 +129,18 @@ class UserStatsService
|
||||
->where('timestamp', '>=', $startTime)
|
||||
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")'))
|
||||
->orderBy('minute', 'asc')
|
||||
->get();
|
||||
->get()
|
||||
->keyBy('minute');
|
||||
|
||||
// Fill in missing minutes with zero counts
|
||||
// Fill in missing minutes with zero counts (O(n) instead of firstWhere O(n^2))
|
||||
$result = [];
|
||||
for ($i = $minutes - 1; $i >= 0; $i--) {
|
||||
$time = Carbon::now()->subMinutes($i);
|
||||
$minuteKey = $time->format('Y-m-d H:i:00');
|
||||
$found = $downloads->firstWhere('minute', $minuteKey);
|
||||
$found = $downloads->get($minuteKey);
|
||||
$result[] = [
|
||||
'time' => $time->format('H:i'),
|
||||
'count' => $found ? $found->count : 0,
|
||||
'count' => $found ? (int) $found->count : 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -241,17 +242,18 @@ class UserStatsService
|
||||
->where('timestamp', '>=', $startTime)
|
||||
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")'))
|
||||
->orderBy('minute', 'asc')
|
||||
->get();
|
||||
->get()
|
||||
->keyBy('minute');
|
||||
|
||||
// Fill in missing minutes with zero counts
|
||||
// Fill in missing minutes with zero counts (O(n) instead of firstWhere O(n^2))
|
||||
$result = [];
|
||||
for ($i = $minutes - 1; $i >= 0; $i--) {
|
||||
$time = Carbon::now()->subMinutes($i);
|
||||
$minuteKey = $time->format('Y-m-d H:i:00');
|
||||
$found = $apiHits->firstWhere('minute', $minuteKey);
|
||||
$found = $apiHits->get($minuteKey);
|
||||
$result[] = [
|
||||
'time' => $time->format('H:i'),
|
||||
'count' => $found ? $found->count : 0,
|
||||
'count' => $found ? (int) $found->count : 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -270,31 +272,36 @@ class UserStatsService
|
||||
$twoDaysAgo = Carbon::now()->subDays(2)->startOfDay();
|
||||
$sevenDaysAgo = Carbon::now()->subDays(7)->startOfDay();
|
||||
|
||||
// For weekly stats, combine aggregated historical data + live recent data
|
||||
$historicalDownloads = UserActivityStat::query()
|
||||
// Combine weekly historical totals + today/2-day live counts in a single
|
||||
// query per source table using conditional aggregation.
|
||||
$historical = UserActivityStat::query()
|
||||
->where('stat_date', '>=', $sevenDaysAgo->format('Y-m-d'))
|
||||
->where('stat_date', '<', $twoDaysAgo->format('Y-m-d'))
|
||||
->sum('downloads_count');
|
||||
->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits')
|
||||
->first();
|
||||
|
||||
$recentDownloads = UserDownload::query()
|
||||
$downloadAgg = UserDownload::query()
|
||||
->where('timestamp', '>=', $twoDaysAgo)
|
||||
->count();
|
||||
->selectRaw(
|
||||
'SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END) as today_count, COUNT(*) as recent_count',
|
||||
[$today]
|
||||
)
|
||||
->first();
|
||||
|
||||
$historicalApiHits = UserActivityStat::query()
|
||||
->where('stat_date', '>=', $sevenDaysAgo->format('Y-m-d'))
|
||||
->where('stat_date', '<', $twoDaysAgo->format('Y-m-d'))
|
||||
->sum('api_hits_count');
|
||||
|
||||
$recentApiHits = UserRequest::query()
|
||||
$apiAgg = UserRequest::query()
|
||||
->where('timestamp', '>=', $twoDaysAgo)
|
||||
->count();
|
||||
->selectRaw(
|
||||
'SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END) as today_count, COUNT(*) as recent_count',
|
||||
[$today]
|
||||
)
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_users' => User::whereNull('deleted_at')->count(),
|
||||
'downloads_today' => UserDownload::where('timestamp', '>=', $today)->count(),
|
||||
'downloads_week' => $historicalDownloads + $recentDownloads,
|
||||
'api_hits_today' => UserRequest::query()->where('timestamp', '>=', $today)->count(),
|
||||
'api_hits_week' => $historicalApiHits + $recentApiHits,
|
||||
'downloads_today' => (int) ($downloadAgg->today_count ?? 0),
|
||||
'downloads_week' => (int) ($historical->downloads ?? 0) + (int) ($downloadAgg->recent_count ?? 0),
|
||||
'api_hits_today' => (int) ($apiAgg->today_count ?? 0),
|
||||
'api_hits_week' => (int) ($historical->api_hits ?? 0) + (int) ($apiAgg->recent_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Returns approximate row counts for large tables using
|
||||
* information_schema.TABLES.TABLE_ROWS so we avoid COUNT(*) full scans
|
||||
* on the hottest dashboard tiles.
|
||||
*
|
||||
* Falls back to an exact COUNT(*) when:
|
||||
* - the connection isn't MySQL/MariaDB, or
|
||||
* - the approximation is unavailable / reports 0 (common for tiny tables).
|
||||
*/
|
||||
final class ApproximateRowCount
|
||||
{
|
||||
/**
|
||||
* Approximate count for a single table.
|
||||
*/
|
||||
public static function for(string $table, ?string $connection = null): int
|
||||
{
|
||||
$conn = DB::connection($connection);
|
||||
|
||||
if (! self::supportsInformationSchema($conn)) {
|
||||
return (int) $conn->table($table)->count();
|
||||
}
|
||||
|
||||
try {
|
||||
$row = $conn->selectOne(
|
||||
'SELECT TABLE_ROWS AS approx FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1',
|
||||
[$table]
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$row = null;
|
||||
}
|
||||
|
||||
$approx = $row?->approx ?? null;
|
||||
|
||||
if ($approx === null || (int) $approx <= 0) {
|
||||
// Fall back to exact count on tables InnoDB hasn't analyzed yet
|
||||
// or that are small enough that the approximation is meaningless.
|
||||
return (int) $conn->table($table)->count();
|
||||
}
|
||||
|
||||
return (int) $approx;
|
||||
}
|
||||
|
||||
private static function supportsInformationSchema(ConnectionInterface $conn): bool
|
||||
{
|
||||
$driver = method_exists($conn, 'getDriverName') ? $conn->getDriverName() : null;
|
||||
|
||||
return in_array($driver, ['mysql', 'mariadb'], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add a dedicated boolean column for "permanent" deletions on user_activities,
|
||||
* so the admin dashboard no longer needs whereJsonContains() (which can't use
|
||||
* a regular index) to count permanently-deleted users.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('user_activities')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('user_activities', 'is_permanent')) {
|
||||
Schema::table('user_activities', function (Blueprint $table): void {
|
||||
$table->boolean('is_permanent')->default(false)->after('metadata');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $this->indexExists('user_activities', 'ix_user_activities_type_permanent')) {
|
||||
Schema::table('user_activities', function (Blueprint $table): void {
|
||||
$table->index(['activity_type', 'is_permanent'], 'ix_user_activities_type_permanent');
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill existing rows where metadata->permanent is true.
|
||||
DB::table('user_activities')
|
||||
->where('activity_type', 'deleted')
|
||||
->whereRaw("JSON_EXTRACT(metadata, '$.permanent') = true")
|
||||
->update(['is_permanent' => true]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('user_activities')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->indexExists('user_activities', 'ix_user_activities_type_permanent')) {
|
||||
Schema::table('user_activities', function (Blueprint $table): void {
|
||||
$table->dropIndex('ix_user_activities_type_permanent');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('user_activities', 'is_permanent')) {
|
||||
Schema::table('user_activities', function (Blueprint $table): void {
|
||||
$table->dropColumn('is_permanent');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function indexExists(string $table, string $indexName): bool
|
||||
{
|
||||
try {
|
||||
$rows = DB::select("SHOW INDEX FROM {$table} WHERE Key_name = ?", [$indexName]);
|
||||
|
||||
return count($rows) > 0;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
/**
|
||||
* Alpine.data('adminDashboard') - Admin dashboard charts with widget auto-refresh.
|
||||
* Alpine.data('adminDashboard') - Admin dashboard with deferred widget loading.
|
||||
*
|
||||
* The Blade view ships only the lightweight stat tiles + registration status
|
||||
* synchronously. All heavy widgets (User Statistics, System Resources history,
|
||||
* Recent Activity, Site Status, System Status) render skeleton placeholders
|
||||
* and are populated by fetching the cached `admin.api.dashboard-data` payload
|
||||
* on mount. The cache is warmed every minute by the scheduler so this fetch
|
||||
* is essentially free.
|
||||
*/
|
||||
import Alpine from '@alpinejs/csp';
|
||||
import Chart from 'chart.js/auto';
|
||||
|
||||
const STATUS_BADGE_CLASSES = {
|
||||
operational: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
|
||||
degraded: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
|
||||
maintenance: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
|
||||
partial_outage: 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
|
||||
major_outage: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
|
||||
};
|
||||
const IMPACT_BADGE_CLASSES = {
|
||||
critical: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
|
||||
major: 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
|
||||
minor: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
|
||||
};
|
||||
const SYSTEM_SERVICE_SLUGS = ['database', 'redis', 'queue', 'disk'];
|
||||
const formatNumber = (value) => Number(value || 0).toLocaleString();
|
||||
const escapeHtml = (str) => String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const badgeClass = (status) => 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full '
|
||||
+ (STATUS_BADGE_CLASSES[status] ?? 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200');
|
||||
Alpine.data('adminDashboard', () => ({
|
||||
_charts: [],
|
||||
_refreshInterval: null,
|
||||
_visibilityHandler: null,
|
||||
_lastRefreshAt: Date.now(),
|
||||
_isRefreshing: false,
|
||||
|
||||
init() {
|
||||
this._initCharts();
|
||||
this._loadDashboardData();
|
||||
this._scheduleRefresh();
|
||||
},
|
||||
|
||||
_scheduleRefresh() {
|
||||
const url = this.$el.dataset.refreshUrl;
|
||||
const interval = Number.parseInt(this.$el.dataset.refreshInterval ?? '', 10) || (15 * 60 * 1000);
|
||||
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._refreshInterval = window.setInterval(() => this._refreshDashboard(url), interval);
|
||||
this._refreshInterval = window.setInterval(() => this._loadDashboardData(), interval);
|
||||
this._visibilityHandler = () => {
|
||||
if (!document.hidden && Date.now() - this._lastRefreshAt >= interval) {
|
||||
this._refreshDashboard(url);
|
||||
this._loadDashboardData();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', this._visibilityHandler);
|
||||
},
|
||||
|
||||
_refreshDashboard(url) {
|
||||
if (this._isRefreshing) {
|
||||
_loadDashboardData() {
|
||||
const url = this.$el.dataset.dataUrl;
|
||||
if (!url || this._isRefreshing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = this.$el.querySelector('[data-dashboard-content]');
|
||||
|
||||
if (!currentContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isRefreshing = true;
|
||||
|
||||
fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html',
|
||||
},
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to refresh dashboard.');
|
||||
}
|
||||
|
||||
return response.text();
|
||||
if (!response.ok) throw new Error('Failed to load dashboard data.');
|
||||
return response.json();
|
||||
})
|
||||
.then(html => {
|
||||
const parsedDocument = new DOMParser().parseFromString(html, 'text/html');
|
||||
const nextContent = parsedDocument.querySelector('#adminDashboard [data-dashboard-content]');
|
||||
|
||||
if (!nextContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._destroyCharts();
|
||||
currentContent.innerHTML = nextContent.innerHTML;
|
||||
this._initCharts();
|
||||
.then(payload => {
|
||||
this._renderUserStats(payload.userStats);
|
||||
this._renderSystemMetrics(payload.systemMetrics);
|
||||
this._renderRecentActivity(payload.recent_activity);
|
||||
this._renderSiteStatus(payload.serviceStatuses, payload.activeIncidents);
|
||||
this._renderSystemServices(payload.serviceStatuses);
|
||||
this._lastRefreshAt = Date.now();
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -80,40 +80,222 @@ Alpine.data('adminDashboard', () => ({
|
||||
this._isRefreshing = false;
|
||||
});
|
||||
},
|
||||
|
||||
_initCharts() {
|
||||
this._destroyCharts();
|
||||
this._initChart('downloadsChart', 'bar', 'Downloads', 'rgba(34,197,94,0.7)', 'rgba(34,197,94,1)', false);
|
||||
this._initChart('apiHitsChart', 'line', 'API Hits', 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true);
|
||||
this._initChart('downloadsMinuteChart', 'line', 'Downloads', 'rgba(34,197,94,0.2)', 'rgba(34,197,94,1)', true);
|
||||
this._initChart('apiHitsMinuteChart', 'line', 'API Hits', 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true);
|
||||
this._initPercentChart('cpuHistory24hChart', 'CPU Usage %', 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)');
|
||||
this._initPercentChart('ramHistory24hChart', 'RAM Usage %', 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)');
|
||||
this._initPercentChart('cpuHistory30dChart', 'CPU Usage %', 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)');
|
||||
this._initPercentChart('ramHistory30dChart', 'RAM Usage %', 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)');
|
||||
_swapWidget(name) {
|
||||
const widget = this.$el.querySelector(`[data-widget="${name}"]`);
|
||||
if (!widget) return null;
|
||||
widget.querySelector('[data-widget-loading]')?.classList.add('hidden');
|
||||
const content = widget.querySelector('[data-widget-content]');
|
||||
content?.classList.remove('hidden');
|
||||
return widget;
|
||||
},
|
||||
|
||||
_parseChartData(rawData) {
|
||||
try {
|
||||
return JSON.parse(rawData || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
_renderUserStats(stats) {
|
||||
if (!stats) return;
|
||||
const widget = this._swapWidget('user-stats');
|
||||
if (!widget) return;
|
||||
const summary = stats.summary ?? {};
|
||||
const cards = [
|
||||
{ label: 'Total Users', value: summary.total_users, theme: 'blue' },
|
||||
{ label: 'Downloads Today', value: summary.downloads_today, theme: 'green' },
|
||||
{ label: 'Downloads (7d)', value: summary.downloads_week, theme: 'purple' },
|
||||
{ label: 'API Hits Today', value: summary.api_hits_today, theme: 'orange' },
|
||||
{ label: 'API Hits (7d)', value: summary.api_hits_week, theme: 'pink' },
|
||||
];
|
||||
const summaryGrid = widget.querySelector('[data-summary-grid]');
|
||||
if (summaryGrid) {
|
||||
summaryGrid.innerHTML = cards.map(c => `
|
||||
<div class="bg-linear-to-br from-${c.theme}-50 to-${c.theme}-100 dark:from-${c.theme}-900 dark:to-${c.theme}-800 rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-${c.theme}-600 dark:text-${c.theme}-300 font-medium mb-1">${escapeHtml(c.label)}</p>
|
||||
<p class="text-2xl font-bold text-${c.theme}-800 dark:text-${c.theme}-100">${formatNumber(c.value)}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
const roles = stats.users_by_role ?? [];
|
||||
const totalUsers = roles.reduce((sum, r) => sum + Number(r.count || 0), 0);
|
||||
const rolesTbody = widget.querySelector('[data-roles-tbody]');
|
||||
if (rolesTbody) {
|
||||
rolesTbody.innerHTML = roles.length === 0
|
||||
? '<tr><td colspan="3" class="px-4 py-3 text-sm text-center text-gray-500 dark:text-gray-400">No data available</td></tr>'
|
||||
: roles.map(r => {
|
||||
const pct = totalUsers > 0 ? ((Number(r.count) / totalUsers) * 100).toFixed(1) : '0';
|
||||
const role = String(r.role ?? '');
|
||||
const roleLabel = role ? role.charAt(0).toUpperCase() + role.slice(1) : '';
|
||||
return `
|
||||
<tr class="hover:bg-gray-100 dark:hover:bg-gray-800 transition">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">${escapeHtml(roleLabel)}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100 font-semibold">${formatNumber(r.count)}</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-gray-600 dark:text-gray-400">${pct}%</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
const downloaders = stats.top_downloaders ?? [];
|
||||
const downloadersTbody = widget.querySelector('[data-downloaders-tbody]');
|
||||
if (downloadersTbody) {
|
||||
const medalIcons = ['fa-medal text-yellow-500', 'fa-medal text-gray-400', 'fa-medal text-orange-600'];
|
||||
downloadersTbody.innerHTML = downloaders.length === 0
|
||||
? '<tr><td colspan="3" class="px-4 py-3 text-sm text-center text-gray-500 dark:text-gray-400">No downloads in the last 7 days</td></tr>'
|
||||
: downloaders.map((d, i) => {
|
||||
const rank = i < 3 ? `<i class="fas ${medalIcons[i]} text-lg"></i>` : `<span class="text-gray-500">${i + 1}</span>`;
|
||||
return `
|
||||
<tr class="hover:bg-gray-100 dark:hover:bg-gray-800 transition">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">${rank}</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">${escapeHtml(d.username)}</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100 font-semibold">${formatNumber(d.download_count)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
this._renderChart('downloadsChart', 'bar', 'Downloads', stats.downloads_per_hour ?? [],
|
||||
'rgba(34,197,94,0.7)', 'rgba(34,197,94,1)', false);
|
||||
this._renderChart('apiHitsChart', 'line', 'API Hits', stats.api_hits_per_hour ?? [],
|
||||
'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true);
|
||||
this._renderChart('downloadsMinuteChart', 'line', 'Downloads', stats.downloads_per_minute ?? [],
|
||||
'rgba(34,197,94,0.2)', 'rgba(34,197,94,1)', true);
|
||||
this._renderChart('apiHitsMinuteChart', 'line', 'API Hits', stats.api_hits_per_minute ?? [],
|
||||
'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true);
|
||||
},
|
||||
_renderSystemMetrics(metrics) {
|
||||
if (!metrics) return;
|
||||
const widget = this._swapWidget('system-metrics');
|
||||
if (!widget) return;
|
||||
const setText = (selector, text) => {
|
||||
const el = widget.querySelector(`[data-metric="${selector}"]`);
|
||||
if (el) el.textContent = text;
|
||||
};
|
||||
const cpu = metrics.cpu ?? {};
|
||||
const ram = metrics.ram ?? {};
|
||||
const load = cpu.load_average ?? {};
|
||||
setText('cpu-current', `${Number(cpu.current ?? 0).toFixed(1)}%`);
|
||||
setText('cpu-cores', cpu.cores ?? '—');
|
||||
setText('cpu-threads', cpu.threads ?? '—');
|
||||
setText('cpu-load-1min', load['1min'] ?? '—');
|
||||
setText('cpu-load-5min', load['5min'] ?? '—');
|
||||
setText('cpu-load-15min', load['15min'] ?? '—');
|
||||
const cpuModel = widget.querySelector('[data-metric="cpu-model"]');
|
||||
if (cpuModel && cpu.model && cpu.model !== 'Unknown') {
|
||||
cpuModel.title = cpu.model;
|
||||
cpuModel.innerHTML = `<i class="fas fa-info-circle mr-1"></i>${escapeHtml(cpu.model)}`;
|
||||
}
|
||||
setText('ram-current', `${Number(ram.percentage ?? 0).toFixed(1)}%`);
|
||||
setText('ram-details', `${Number(ram.used ?? 0).toFixed(2)} GB / ${Number(ram.total ?? 0).toFixed(2)} GB`);
|
||||
this._renderPercentChart('cpuHistory24hChart', 'CPU Usage %', cpu.history_24h ?? [],
|
||||
'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)');
|
||||
this._renderPercentChart('ramHistory24hChart', 'RAM Usage %', ram.history_24h ?? [],
|
||||
'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)');
|
||||
this._renderPercentChart('cpuHistory30dChart', 'CPU Usage %', cpu.history_30d ?? [],
|
||||
'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)');
|
||||
this._renderPercentChart('ramHistory30dChart', 'RAM Usage %', ram.history_30d ?? [],
|
||||
'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)');
|
||||
},
|
||||
_renderRecentActivity(activities) {
|
||||
const widget = this._swapWidget('recent-activity');
|
||||
if (!widget) return;
|
||||
const updated = widget.querySelector('[data-metric="activity-updated"]');
|
||||
if (updated) {
|
||||
updated.textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
const container = widget.querySelector('#recent-activity-container');
|
||||
if (!container) return;
|
||||
if (!Array.isArray(activities) || activities.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-4">No recent activity</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = activities.map(activity => {
|
||||
let extra = '';
|
||||
if (activity.type === 'deleted' && activity.metadata && activity.metadata.deleted_by) {
|
||||
const permanent = activity.metadata.permanent ? ', permanent' : '';
|
||||
extra = `<span class="text-xs text-gray-500 dark:text-gray-400"> (by ${escapeHtml(activity.metadata.deleted_by)}${escapeHtml(permanent)})</span>`;
|
||||
}
|
||||
return `
|
||||
<div class="flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
|
||||
<div class="w-8 h-8 ${activity.icon_bg} rounded-full flex items-center justify-center mr-3 shrink-0">
|
||||
<i class="fas fa-${escapeHtml(activity.icon)} ${activity.icon_color} text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200">${escapeHtml(activity.message)}${extra}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">${escapeHtml(activity.created_at_human ?? '')}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
},
|
||||
_renderSiteStatus(services, incidents) {
|
||||
const widget = this._swapWidget('site-status');
|
||||
if (!widget) return;
|
||||
const grid = widget.querySelector('[data-services-grid]');
|
||||
if (grid) {
|
||||
grid.innerHTML = (services ?? []).map(svc => `
|
||||
<div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-100 dark:border-gray-700">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">${escapeHtml(svc.name)}</span>
|
||||
<span class="block text-xs text-gray-500 dark:text-gray-400">${Number(svc.uptime_percentage).toFixed(2)}% uptime</span>
|
||||
</div>
|
||||
<span class="${badgeClass(svc.status)}">${escapeHtml(svc.status_label)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
const region = widget.querySelector('[data-incidents-region]');
|
||||
if (!region) return;
|
||||
if (!Array.isArray(incidents) || incidents.length === 0) {
|
||||
region.innerHTML = '<p class="text-sm text-green-600 dark:text-green-400"><i class="fas fa-check-circle mr-1"></i>No active incidents</p>';
|
||||
return;
|
||||
}
|
||||
const visible = incidents.slice(0, 5);
|
||||
const overflow = incidents.length - visible.length;
|
||||
const items = visible.map(inc => {
|
||||
const impactClass = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full shrink-0 '
|
||||
+ (IMPACT_BADGE_CLASSES[inc.impact] ?? 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200');
|
||||
const autoBadge = inc.is_auto
|
||||
? '<span class="ml-1 px-1 py-0.5 text-[10px] leading-3 font-semibold rounded bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200">Auto</span>'
|
||||
: '';
|
||||
const title = String(inc.title ?? '');
|
||||
const truncated = title.length > 50 ? `${title.slice(0, 50)}…` : title;
|
||||
return `
|
||||
<div class="flex items-start justify-between gap-2 text-sm py-1">
|
||||
<div class="min-w-0">
|
||||
<span class="text-gray-800 dark:text-gray-200 truncate block">${escapeHtml(truncated)}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">${escapeHtml((inc.services ?? []).join(', '))} · ${escapeHtml(inc.started_at_human ?? '')}${autoBadge}</span>
|
||||
</div>
|
||||
<span class="${impactClass}">${escapeHtml(inc.impact_label)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
region.innerHTML = `
|
||||
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>${incidents.length} Active Incident${incidents.length === 1 ? '' : 's'}
|
||||
</h4>
|
||||
<div class="space-y-2">${items}${overflow > 0 ? `<p class="text-xs text-gray-500 dark:text-gray-400 pt-1">+ ${overflow} more</p>` : ''}</div>
|
||||
`;
|
||||
},
|
||||
_renderSystemServices(services) {
|
||||
const widget = this._swapWidget('system-status');
|
||||
if (!widget) return;
|
||||
const container = widget.querySelector('[data-system-services]');
|
||||
if (!container) return;
|
||||
const bySlug = {};
|
||||
(services ?? []).forEach(svc => { bySlug[svc.slug] = svc; });
|
||||
container.innerHTML = SYSTEM_SERVICE_SLUGS.map(slug => {
|
||||
const svc = bySlug[slug];
|
||||
const label = svc?.name ?? (slug.charAt(0).toUpperCase() + slug.slice(1));
|
||||
const right = svc
|
||||
? `<span class="${badgeClass(svc.status)}">${escapeHtml(svc.status_label)}</span>`
|
||||
: '<span class="px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-full text-sm">Not configured</span>';
|
||||
return `
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">${escapeHtml(label)}</span>
|
||||
${right}
|
||||
</div>`;
|
||||
}).join('');
|
||||
},
|
||||
_destroyChart(canvasId) {
|
||||
const existing = this._charts.find(c => c.canvas?.id === canvasId);
|
||||
if (existing) {
|
||||
existing.destroy();
|
||||
this._charts = this._charts.filter(c => c !== existing);
|
||||
}
|
||||
},
|
||||
|
||||
_initChart(canvasId, type, label, backgroundColor, borderColor, fill) {
|
||||
_renderChart(canvasId, type, label, data, backgroundColor, borderColor, fill) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = this._parseChartData(canvas.dataset.chartData || canvas.dataset.history);
|
||||
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canvas || !Array.isArray(data) || data.length === 0) return;
|
||||
this._destroyChart(canvasId);
|
||||
const chart = new Chart(canvas, {
|
||||
type,
|
||||
data: {
|
||||
@@ -131,38 +313,16 @@ Alpine.data('adminDashboard', () => ({
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
|
||||
plugins: { legend: { display: false } },
|
||||
},
|
||||
});
|
||||
|
||||
this._charts.push(chart);
|
||||
},
|
||||
|
||||
_initPercentChart(canvasId, label, backgroundColor, borderColor) {
|
||||
_renderPercentChart(canvasId, label, data, backgroundColor, borderColor) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = this._parseChartData(canvas.dataset.history);
|
||||
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canvas || !Array.isArray(data) || data.length === 0) return;
|
||||
this._destroyChart(canvasId);
|
||||
const chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -180,40 +340,19 @@ Alpine.data('adminDashboard', () => ({
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: value => value + '%',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: { y: { beginAtZero: true, max: 100, ticks: { callback: value => value + '%' } } },
|
||||
plugins: { legend: { display: false } },
|
||||
},
|
||||
});
|
||||
|
||||
this._charts.push(chart);
|
||||
},
|
||||
|
||||
_destroyCharts() {
|
||||
this._charts.forEach(chart => chart.destroy());
|
||||
this._charts = [];
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this._refreshInterval) {
|
||||
window.clearInterval(this._refreshInterval);
|
||||
}
|
||||
|
||||
if (this._visibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
||||
}
|
||||
|
||||
if (this._refreshInterval) window.clearInterval(this._refreshInterval);
|
||||
if (this._visibilityHandler) document.removeEventListener('visibilitychange', this._visibilityHandler);
|
||||
this._destroyCharts();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<div x-data="adminDashboard"
|
||||
id="adminDashboard"
|
||||
data-refresh-url="{{ route('admin.index') }}"
|
||||
data-data-url="{{ route('admin.api.dashboard-data') }}"
|
||||
data-refresh-interval="{{ 15 * 60 * 1000 }}">
|
||||
<div class="space-y-6" data-dashboard-content>
|
||||
<!-- Welcome Section -->
|
||||
@@ -152,8 +153,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Site Status & Active Incidents -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<!-- Site Status & Active Incidents (deferred — populated by Alpine on mount) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700"
|
||||
data-widget="site-status">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center justify-between">
|
||||
<span>
|
||||
<i class="fas fa-signal mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
@@ -161,75 +163,15 @@
|
||||
</span>
|
||||
<a href="{{ route('admin.status.index') }}" class="text-xs text-blue-600 dark:text-blue-400 hover:underline font-normal">Manage →</a>
|
||||
</h3>
|
||||
|
||||
@php
|
||||
$dashBadge = static function (string $status): string {
|
||||
$base = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full';
|
||||
return $base.' '.match ($status) {
|
||||
'operational' => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
|
||||
'degraded' => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
|
||||
'maintenance' => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
|
||||
'partial_outage' => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
|
||||
'major_outage' => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
|
||||
default => 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
|
||||
};
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||||
@foreach($serviceStatuses as $svc)
|
||||
<div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-100 dark:border-gray-700">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $svc->name }}</span>
|
||||
<span class="block text-xs text-gray-500 dark:text-gray-400">{{ number_format((float) $svc->uptime_percentage, 2) }}% uptime</span>
|
||||
<div data-widget-loading class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||||
<div class="h-14 rounded-lg bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
<div class="h-14 rounded-lg bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
<div class="h-14 rounded-lg bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
</div>
|
||||
<span class="{{ $dashBadge($svc->status->value) }}">{{ $svc->status->label() }}</span>
|
||||
<div data-widget-content class="hidden">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4" data-services-grid></div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3" data-incidents-region></div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($activeIncidents->isNotEmpty())
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
{{ $activeIncidents->count() }} Active {{ Str::plural('Incident', $activeIncidents->count()) }}
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
@foreach($activeIncidents->take(5) as $incident)
|
||||
<div class="flex items-start justify-between gap-2 text-sm py-1">
|
||||
<div class="min-w-0">
|
||||
<span class="text-gray-800 dark:text-gray-200 truncate block">{{ Str::limit($incident->title, 50) }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $incident->services->pluck('name')->join(', ') }}
|
||||
· {{ $incident->started_at->diffForHumans() }}
|
||||
@if($incident->is_auto)
|
||||
<span class="ml-1 px-1 py-0.5 text-[10px] leading-3 font-semibold rounded bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200">Auto</span>
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full shrink-0 {{ match($incident->impact->value) {
|
||||
'critical' => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
|
||||
'major' => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
|
||||
'minor' => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200',
|
||||
default => 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
|
||||
} }}">{{ $incident->impact->label() }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
@if($activeIncidents->count() > 5)
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 pt-1">
|
||||
+ {{ $activeIncidents->count() - 5 }} more —
|
||||
<a href="{{ route('admin.status.index') }}" class="text-blue-600 dark:text-blue-400 hover:underline">view all</a>
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<p class="text-sm text-green-600 dark:text-green-400">
|
||||
<i class="fas fa-check-circle mr-1"></i>No active incidents
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Registration Status Widget -->
|
||||
@@ -309,39 +251,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Statistics Widget -->
|
||||
@if(isset($userStats))
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<!-- User Statistics Widget (deferred) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700"
|
||||
data-widget="user-stats">
|
||||
<h3 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-6 flex items-center">
|
||||
<i class="fas fa-chart-line mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
User Statistics
|
||||
</h3>
|
||||
|
||||
<div data-widget-loading class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
@for($i = 0; $i < 5; $i++)
|
||||
<div class="h-24 rounded-lg bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
@endfor
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@for($i = 0; $i < 4; $i++)
|
||||
<div class="h-64 rounded-lg bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
@endfor
|
||||
</div>
|
||||
</div>
|
||||
<div data-widget-content class="hidden">
|
||||
<!-- Summary Stats Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900 dark:to-blue-800 rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-blue-600 dark:text-blue-300 font-medium mb-1">Total Users</p>
|
||||
<p class="text-2xl font-bold text-blue-800 dark:text-blue-100">{{ number_format($userStats['summary']['total_users']) }}</p>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900 dark:to-green-800 rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-green-600 dark:text-green-300 font-medium mb-1">Downloads Today</p>
|
||||
<p class="text-2xl font-bold text-green-800 dark:text-green-100">{{ number_format($userStats['summary']['downloads_today']) }}</p>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900 dark:to-purple-800 rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-purple-600 dark:text-purple-300 font-medium mb-1">Downloads (7d)</p>
|
||||
<p class="text-2xl font-bold text-purple-800 dark:text-purple-100">{{ number_format($userStats['summary']['downloads_week']) }}</p>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900 dark:to-orange-800 rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-orange-600 dark:text-orange-300 font-medium mb-1">API Hits Today</p>
|
||||
<p class="text-2xl font-bold text-orange-800 dark:text-orange-100">{{ number_format($userStats['summary']['api_hits_today']) }}</p>
|
||||
</div>
|
||||
<div class="bg-linear-to-br from-pink-50 to-pink-100 dark:from-pink-900 dark:to-pink-800 rounded-lg p-4 text-center">
|
||||
<p class="text-sm text-pink-600 dark:text-pink-300 font-medium mb-1">API Hits (7d)</p>
|
||||
<p class="text-2xl font-bold text-pink-800 dark:text-pink-100">{{ number_format($userStats['summary']['api_hits_week']) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6" data-summary-grid></div>
|
||||
|
||||
<!-- Tables and Graphs Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Users by Role Table -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
@@ -349,44 +281,14 @@
|
||||
<i class="fas fa-user-tag mr-2 text-indigo-600 dark:text-indigo-400"></i>
|
||||
Users by Role
|
||||
</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Role</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Count</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@php
|
||||
$totalUsers = collect($userStats['users_by_role'])->sum('count');
|
||||
@endphp
|
||||
@foreach($userStats['users_by_role'] as $roleData)
|
||||
<tr class="hover:bg-gray-100 dark:hover:bg-gray-800 transition">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
|
||||
{{ ucfirst($roleData['role']) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100 font-semibold">
|
||||
{{ number_format($roleData['count']) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-gray-600 dark:text-gray-400">
|
||||
{{ $totalUsers > 0 ? number_format(($roleData['count'] / $totalUsers) * 100, 1) : 0 }}%
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@if(empty($userStats['users_by_role']))
|
||||
<tr>
|
||||
<td colspan="3" class="px-4 py-3 text-sm text-center text-gray-500 dark:text-gray-400">
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700" data-roles-tbody></tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<!-- Top Downloaders Table -->
|
||||
@@ -395,155 +297,99 @@
|
||||
<i class="fas fa-trophy mr-2 text-yellow-600 dark:text-yellow-400"></i>
|
||||
Top Downloaders (Last 7 Days)
|
||||
</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead><tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Rank</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Username</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach($userStats['top_downloaders'] as $index => $downloader)
|
||||
<tr class="hover:bg-gray-100 dark:hover:bg-gray-800 transition">
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
@if($index === 0)
|
||||
<i class="fas fa-medal text-yellow-500 text-lg"></i>
|
||||
@elseif($index === 1)
|
||||
<i class="fas fa-medal text-gray-400 text-lg"></i>
|
||||
@elseif($index === 2)
|
||||
<i class="fas fa-medal text-orange-600 text-lg"></i>
|
||||
@else
|
||||
<span class="text-gray-500">{{ $index + 1 }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $downloader['username'] }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100 font-semibold">
|
||||
{{ number_format($downloader['download_count']) }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@if(empty($userStats['top_downloaders']))
|
||||
<tr>
|
||||
<td colspan="3" class="px-4 py-3 text-sm text-center text-gray-500 dark:text-gray-400">
|
||||
No downloads in the last 7 days
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700" data-downloaders-tbody></tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<!-- Downloads Chart -->
|
||||
<!-- Charts -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
|
||||
<i class="fas fa-chart-bar mr-2 text-green-600 dark:text-green-400"></i>
|
||||
Downloads (Last 7 Days - Hourly)
|
||||
</h4>
|
||||
<div class="chart-container">
|
||||
<canvas id="downloadsChart" data-chart-data="{{ json_encode($userStats['downloads_per_hour']) }}"></canvas>
|
||||
<div class="chart-container"><canvas id="downloadsChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Hits Chart -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
|
||||
<i class="fas fa-chart-area mr-2 text-purple-600 dark:text-purple-400"></i>
|
||||
API Hits (Last 7 Days - Hourly)
|
||||
</h4>
|
||||
<div class="chart-container">
|
||||
<canvas id="apiHitsChart" data-chart-data="{{ json_encode($userStats['api_hits_per_hour']) }}"></canvas>
|
||||
<div class="chart-container"><canvas id="apiHitsChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Downloads Per Minute Chart -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
|
||||
<i class="fas fa-chart-line mr-2 text-green-600 dark:text-green-400"></i>
|
||||
Downloads (Last 60 Minutes)
|
||||
</h4>
|
||||
<div class="chart-container">
|
||||
<canvas id="downloadsMinuteChart" data-chart-data="{{ json_encode($userStats['downloads_per_minute']) }}"></canvas>
|
||||
<div class="chart-container"><canvas id="downloadsMinuteChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Hits Per Minute Chart -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
|
||||
<i class="fas fa-chart-line mr-2 text-purple-600 dark:text-purple-400"></i>
|
||||
API Hits (Last 60 Minutes)
|
||||
</h4>
|
||||
<div class="chart-container">
|
||||
<canvas id="apiHitsMinuteChart" data-chart-data="{{ json_encode($userStats['api_hits_per_minute']) }}"></canvas>
|
||||
<div class="chart-container"><canvas id="apiHitsMinuteChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- System Metrics (CPU & RAM) -->
|
||||
@if(isset($systemMetrics))
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<!-- System Metrics (CPU & RAM) — deferred -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700"
|
||||
data-widget="system-metrics">
|
||||
<h3 class="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-6 flex items-center">
|
||||
<i class="fas fa-server mr-2 text-orange-600 dark:text-orange-400"></i>
|
||||
System Resources
|
||||
</h3>
|
||||
|
||||
<!-- Current Usage Stats -->
|
||||
<div data-widget-loading class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="h-40 rounded-lg bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
<div class="h-40 rounded-lg bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@for($i = 0; $i < 4; $i++)
|
||||
<div class="h-56 rounded-lg bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
@endfor
|
||||
</div>
|
||||
</div>
|
||||
<div data-widget-content class="hidden">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- CPU card -->
|
||||
<div class="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900 dark:to-orange-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p class="text-sm text-orange-600 dark:text-orange-300 font-medium mb-1">CPU Usage</p>
|
||||
<p class="text-3xl font-bold text-orange-800 dark:text-orange-100" data-metric="cpu-current">{{ number_format($systemMetrics['cpu']['current'], 1) }}%</p>
|
||||
<p class="text-3xl font-bold text-orange-800 dark:text-orange-100" data-metric="cpu-current">—</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-200 dark:bg-orange-700 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-microchip text-2xl text-orange-600 dark:text-orange-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-orange-200 dark:border-orange-700 pt-3 space-y-2">
|
||||
<div class="flex justify-between text-xs">
|
||||
<span class="text-orange-600 dark:text-orange-300">Cores:</span>
|
||||
<span class="font-semibold text-orange-800 dark:text-orange-100">{{ $systemMetrics['cpu']['cores'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs">
|
||||
<span class="text-orange-600 dark:text-orange-300">Threads:</span>
|
||||
<span class="font-semibold text-orange-800 dark:text-orange-100">{{ $systemMetrics['cpu']['threads'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs">
|
||||
<span class="text-orange-600 dark:text-orange-300">Load (1m):</span>
|
||||
<span class="font-semibold text-orange-800 dark:text-orange-100" data-metric="cpu-load-1min">{{ $systemMetrics['cpu']['load_average']['1min'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs">
|
||||
<span class="text-orange-600 dark:text-orange-300">Load (5m):</span>
|
||||
<span class="font-semibold text-orange-800 dark:text-orange-100" data-metric="cpu-load-5min">{{ $systemMetrics['cpu']['load_average']['5min'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs">
|
||||
<span class="text-orange-600 dark:text-orange-300">Load (15m):</span>
|
||||
<span class="font-semibold text-orange-800 dark:text-orange-100" data-metric="cpu-load-15min">{{ $systemMetrics['cpu']['load_average']['15min'] }}</span>
|
||||
</div>
|
||||
@if($systemMetrics['cpu']['model'] !== 'Unknown')
|
||||
<div class="border-t border-orange-200 dark:border-orange-700 pt-3 space-y-2 text-xs">
|
||||
<div class="flex justify-between"><span class="text-orange-600 dark:text-orange-300">Cores:</span><span class="font-semibold text-orange-800 dark:text-orange-100" data-metric="cpu-cores">—</span></div>
|
||||
<div class="flex justify-between"><span class="text-orange-600 dark:text-orange-300">Threads:</span><span class="font-semibold text-orange-800 dark:text-orange-100" data-metric="cpu-threads">—</span></div>
|
||||
<div class="flex justify-between"><span class="text-orange-600 dark:text-orange-300">Load (1m):</span><span class="font-semibold text-orange-800 dark:text-orange-100" data-metric="cpu-load-1min">—</span></div>
|
||||
<div class="flex justify-between"><span class="text-orange-600 dark:text-orange-300">Load (5m):</span><span class="font-semibold text-orange-800 dark:text-orange-100" data-metric="cpu-load-5min">—</span></div>
|
||||
<div class="flex justify-between"><span class="text-orange-600 dark:text-orange-300">Load (15m):</span><span class="font-semibold text-orange-800 dark:text-orange-100" data-metric="cpu-load-15min">—</span></div>
|
||||
<div class="pt-2 border-t border-orange-200 dark:border-orange-700">
|
||||
<p class="text-xs text-orange-600 dark:text-orange-300 truncate" title="{{ $systemMetrics['cpu']['model'] }}">
|
||||
<i class="fas fa-info-circle mr-1"></i>{{ $systemMetrics['cpu']['model'] }}
|
||||
</p>
|
||||
<p class="text-xs text-orange-600 dark:text-orange-300 truncate" data-metric="cpu-model"></p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RAM card -->
|
||||
<div class="bg-linear-to-br from-cyan-50 to-cyan-100 dark:from-cyan-900 dark:to-cyan-800 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-cyan-600 dark:text-cyan-300 font-medium mb-1">RAM Usage</p>
|
||||
<p class="text-3xl font-bold text-cyan-800 dark:text-cyan-100" data-metric="ram-current">{{ number_format($systemMetrics['ram']['percentage'], 1) }}%</p>
|
||||
<p class="text-xs text-cyan-600 dark:text-cyan-300 mt-1" data-metric="ram-details">
|
||||
{{ number_format($systemMetrics['ram']['used'], 2) }} GB / {{ number_format($systemMetrics['ram']['total'], 2) }} GB
|
||||
</p>
|
||||
<p class="text-3xl font-bold text-cyan-800 dark:text-cyan-100" data-metric="ram-current">—</p>
|
||||
<p class="text-xs text-cyan-600 dark:text-cyan-300 mt-1" data-metric="ram-details">—</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-cyan-200 dark:bg-cyan-700 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-memory text-2xl text-cyan-600 dark:text-cyan-300"></i>
|
||||
@@ -552,152 +398,75 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Updated Indicator -->
|
||||
<div class="mb-4 text-right">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-sync-alt mr-1"></i>Last dashboard refresh: <span data-metric="last-updated">{{ $dashboardLastRefreshedAt }}</span>
|
||||
<span class="ml-2 text-green-600 dark:text-green-400">(Auto-refreshes every 15 minutes)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Historical Graphs -->
|
||||
<div class="space-y-6">
|
||||
<!-- 24 Hour Charts -->
|
||||
<div>
|
||||
<h4 class="text-md font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<i class="fas fa-clock mr-2 text-blue-600 dark:text-blue-400"></i>
|
||||
Last 24 Hours (Hour by Hour)
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- CPU Usage 24h Graph -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h5 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
|
||||
<i class="fas fa-microchip mr-2 text-orange-600 dark:text-orange-400"></i>
|
||||
CPU Usage
|
||||
</h5>
|
||||
<div class="chart-container">
|
||||
<canvas id="cpuHistory24hChart"
|
||||
data-history="{{ json_encode($systemMetrics['cpu']['history_24h']) }}"
|
||||
data-chart-label="CPU Usage %"></canvas>
|
||||
<h5 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3"><i class="fas fa-microchip mr-2 text-orange-600 dark:text-orange-400"></i>CPU Usage</h5>
|
||||
<div class="chart-container"><canvas id="cpuHistory24hChart" data-chart-label="CPU Usage %"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RAM Usage 24h Graph -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h5 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
|
||||
<i class="fas fa-memory mr-2 text-cyan-600 dark:text-cyan-400"></i>
|
||||
RAM Usage
|
||||
</h5>
|
||||
<div class="chart-container">
|
||||
<canvas id="ramHistory24hChart"
|
||||
data-history="{{ json_encode($systemMetrics['ram']['history_24h']) }}"
|
||||
data-chart-label="RAM Usage %"></canvas>
|
||||
<h5 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3"><i class="fas fa-memory mr-2 text-cyan-600 dark:text-cyan-400"></i>RAM Usage</h5>
|
||||
<div class="chart-container"><canvas id="ramHistory24hChart" data-chart-label="RAM Usage %"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 30 Day Charts -->
|
||||
<div>
|
||||
<h4 class="text-md font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<i class="fas fa-calendar-alt mr-2 text-purple-600 dark:text-purple-400"></i>
|
||||
Last 30 Days (Day by Day)
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- CPU Usage 30d Graph -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h5 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
|
||||
<i class="fas fa-microchip mr-2 text-orange-600 dark:text-orange-400"></i>
|
||||
CPU Usage
|
||||
</h5>
|
||||
<div class="chart-container">
|
||||
<canvas id="cpuHistory30dChart"
|
||||
data-history="{{ json_encode($systemMetrics['cpu']['history_30d']) }}"
|
||||
data-chart-label="CPU Usage %"></canvas>
|
||||
<h5 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3"><i class="fas fa-microchip mr-2 text-orange-600 dark:text-orange-400"></i>CPU Usage</h5>
|
||||
<div class="chart-container"><canvas id="cpuHistory30dChart" data-chart-label="CPU Usage %"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RAM Usage 30d Graph -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h5 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
|
||||
<i class="fas fa-memory mr-2 text-cyan-600 dark:text-cyan-400"></i>
|
||||
RAM Usage
|
||||
</h5>
|
||||
<div class="chart-container">
|
||||
<canvas id="ramHistory30dChart"
|
||||
data-history="{{ json_encode($systemMetrics['ram']['history_30d']) }}"
|
||||
data-chart-label="RAM Usage %"></canvas>
|
||||
<h5 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3"><i class="fas fa-memory mr-2 text-cyan-600 dark:text-cyan-400"></i>RAM Usage</h5>
|
||||
<div class="chart-container"><canvas id="ramHistory30dChart" data-chart-label="RAM Usage %"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- System Status -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<!-- System Status (deferred) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700"
|
||||
data-widget="system-status">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">System Status</h3>
|
||||
@php
|
||||
$systemServiceSlugs = ['database', 'redis', 'queue', 'disk'];
|
||||
$systemServices = $serviceStatuses->keyBy('slug');
|
||||
@endphp
|
||||
<div class="space-y-3">
|
||||
@foreach($systemServiceSlugs as $slug)
|
||||
@php $svc = $systemServices->get($slug); @endphp
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ $svc?->name ?? ucfirst($slug) }}</span>
|
||||
@if($svc)
|
||||
<span class="{{ $dashBadge($svc->status->value) }}">
|
||||
{{ $svc->status->label() }}
|
||||
</span>
|
||||
@else
|
||||
<span class="px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-full text-sm">
|
||||
Not configured
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
<div data-widget-loading class="space-y-3">
|
||||
@for($i = 0; $i < 4; $i++)
|
||||
<div class="h-6 rounded bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
@endfor
|
||||
</div>
|
||||
<div data-widget-content class="hidden space-y-3" data-system-services></div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
|
||||
<!-- Recent Activity (deferred) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700"
|
||||
data-widget="recent-activity">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center justify-between">
|
||||
<span>
|
||||
<i class="fas fa-history mr-2 text-indigo-600 dark:text-indigo-400"></i>
|
||||
Recent User Activity
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" id="activity-last-updated">
|
||||
<i class="fas fa-sync-alt"></i> Last dashboard refresh: {{ $dashboardLastRefreshedAt }}
|
||||
<i class="fas fa-sync-alt"></i> <span data-metric="activity-updated">loading…</span>
|
||||
</span>
|
||||
</h3>
|
||||
<div class="space-y-3" id="recent-activity-container">
|
||||
@if(isset($recent_activity) && count($recent_activity) > 0)
|
||||
@foreach($recent_activity as $activity)
|
||||
<div class="flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
|
||||
<div class="w-8 h-8 {{ $activity->icon_bg }} rounded-full flex items-center justify-center mr-3 shrink-0">
|
||||
<i class="fas fa-{{ $activity->icon }} {{ $activity->icon_color }} text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200">
|
||||
{{ $activity->message }}
|
||||
@if($activity->type === 'deleted' && isset($activity->metadata['deleted_by']))
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
(by {{ $activity->metadata['deleted_by'] }}{{ isset($activity->metadata['permanent']) ? ', permanent' : '' }})
|
||||
</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $activity->created_at->diffForHumans() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@else
|
||||
<p class="text-gray-500 dark:text-gray-400 text-center py-4" id="no-activity-message">No recent activity</p>
|
||||
@endif
|
||||
<div data-widget-loading class="space-y-3">
|
||||
@for($i = 0; $i < 5; $i++)
|
||||
<div class="h-10 rounded bg-gray-100 dark:bg-gray-900 animate-pulse"></div>
|
||||
@endfor
|
||||
</div>
|
||||
<div data-widget-content class="hidden space-y-3" id="recent-activity-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,3 +60,6 @@ Schedule::call(function () {
|
||||
// Check tmux health and auto-restart if monitor pane is dead
|
||||
Schedule::command('tmux:health-check --auto-restart')->everyThirtyMinutes()->withoutOverlapping();
|
||||
Schedule::command('nntmux:check-service-health')->everyMinute()->withoutOverlapping();
|
||||
// Keep the admin dashboard snapshot (Cache::flexible) hot so admins never pay
|
||||
// the cold-cache cost when opening /admin/index.
|
||||
Schedule::command('admin:warm-dashboard')->everyMinute()->withoutOverlapping();
|
||||
|
||||
@@ -216,6 +216,10 @@ Route::middleware(['auth', 'isVerified'])->group(function () {
|
||||
Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () {
|
||||
Route::get('index', [AdminPageController::class, 'index'])->name('admin.index');
|
||||
|
||||
// Aggregated JSON payload consumed by the admin dashboard's deferred widgets
|
||||
// (User Statistics, System Resources history, Recent Activity, Site Status).
|
||||
Route::get('api/dashboard-data', [AdminPageController::class, 'getDashboardData'])->name('admin.api.dashboard-data');
|
||||
|
||||
// System Metrics API endpoints
|
||||
Route::get('api/system-metrics/current', [AdminPageController::class, 'getCurrentMetrics'])->name('admin.api.metrics.current');
|
||||
Route::get('api/system-metrics/historical', [AdminPageController::class, 'getHistoricalMetrics'])->name('admin.api.metrics.historical');
|
||||
|
||||
Reference in New Issue
Block a user