lots of progress
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CheckBrokenLinks;
|
||||
use App\Models\Site;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
class MonitorLinksCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'monitor:links';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check for broken links on all enabled sites';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting broken link monitoring...');
|
||||
|
||||
// Fetch all sites with monitoring enabled
|
||||
$sites = Site::where('monitoring_enabled', true)->get();
|
||||
|
||||
if ($sites->isEmpty()) {
|
||||
$this->warn('No enabled sites found for broken link monitoring.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$sites->count()} enabled site(s) for broken link monitoring.");
|
||||
|
||||
// Create a batch of jobs to dispatch in parallel
|
||||
$jobs = $sites->map(function (Site $site) {
|
||||
return new CheckBrokenLinks($site);
|
||||
});
|
||||
|
||||
// Dispatch jobs using Laravel's bus with batch processing
|
||||
Bus::chain([
|
||||
function () use ($jobs) {
|
||||
Bus::batch($jobs->toArray())
|
||||
->name('Broken Link Monitoring')
|
||||
->allowFailures()
|
||||
->onQueue('monitoring')
|
||||
->dispatch();
|
||||
}
|
||||
])->dispatch();
|
||||
|
||||
$this->info("Dispatched {$jobs->count()} broken link check job(s).");
|
||||
$this->comment('Jobs are being processed in the queue...');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CheckSslCertificate;
|
||||
use App\Models\Site;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
class MonitorSslCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'monitor:ssl';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check SSL certificates for all enabled sites';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting SSL certificate monitoring...');
|
||||
|
||||
// Fetch all sites with monitoring enabled
|
||||
$sites = Site::where('monitoring_enabled', true)->get();
|
||||
|
||||
if ($sites->isEmpty()) {
|
||||
$this->warn('No enabled sites found for SSL monitoring.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$sites->count()} enabled site(s) for SSL monitoring.");
|
||||
|
||||
// Create a batch of jobs to dispatch in parallel
|
||||
$jobs = $sites->map(function (Site $site) {
|
||||
return new CheckSslCertificate($site);
|
||||
});
|
||||
|
||||
// Dispatch jobs using Laravel's bus with batch processing
|
||||
Bus::chain([
|
||||
function () use ($jobs) {
|
||||
Bus::batch($jobs->toArray())
|
||||
->name('SSL Certificate Monitoring')
|
||||
->allowFailures()
|
||||
->onQueue('monitoring')
|
||||
->dispatch();
|
||||
}
|
||||
])->dispatch();
|
||||
|
||||
$this->info("Dispatched {$jobs->count()} SSL certificate check job(s).");
|
||||
$this->comment('Jobs are being processed in the queue...');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\CheckUptime;
|
||||
use App\Models\Site;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
class MonitorUptimeCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'monitor:uptime';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check uptime for all enabled sites';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting uptime monitoring...');
|
||||
|
||||
// Fetch all sites with monitoring enabled
|
||||
$sites = Site::where('monitoring_enabled', true)->get();
|
||||
|
||||
if ($sites->isEmpty()) {
|
||||
$this->warn('No enabled sites found for monitoring.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$sites->count()} enabled site(s) to monitor.");
|
||||
|
||||
// Create a batch of jobs to dispatch in parallel
|
||||
$jobs = $sites->map(function (Site $site) {
|
||||
return new CheckUptime($site);
|
||||
});
|
||||
|
||||
// Dispatch jobs using Laravel's bus with concurrency limit
|
||||
Bus::chain([
|
||||
function () use ($jobs) {
|
||||
// Using bus batch for parallel processing
|
||||
Bus::batch($jobs->toArray())
|
||||
->name('Uptime Monitoring')
|
||||
->allowFailures()
|
||||
->onQueue('monitoring')
|
||||
->dispatch();
|
||||
}
|
||||
])->dispatch();
|
||||
|
||||
$this->info("Dispatched {$jobs->count()} uptime check job(s).");
|
||||
$this->comment('Jobs are being processed in the queue...');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AlertController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$notifications = auth()->user()->notifications()
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
$unreadCount = auth()->user()->notifications()
|
||||
->where('is_read', false)
|
||||
->count();
|
||||
|
||||
$readCount = auth()->user()->notifications()
|
||||
->where('is_read', true)
|
||||
->count();
|
||||
|
||||
return view('alerts.index', compact('notifications', 'unreadCount', 'readCount'));
|
||||
}
|
||||
|
||||
public function markAsRead(Notification $notification)
|
||||
{
|
||||
$this->authorize('view', $notification);
|
||||
|
||||
$notification->update(['is_read' => true]);
|
||||
|
||||
return redirect()->route('alerts.index')
|
||||
->with('success', 'Alert marked as read.');
|
||||
}
|
||||
|
||||
public function markAllAsRead()
|
||||
{
|
||||
auth()->user()->notifications()
|
||||
->where('is_read', false)
|
||||
->update(['is_read' => true]);
|
||||
|
||||
return redirect()->route('alerts.index')
|
||||
->with('success', 'All alerts marked as read.');
|
||||
}
|
||||
|
||||
public function destroy(Notification $notification)
|
||||
{
|
||||
$this->authorize('view', $notification);
|
||||
|
||||
$notification->delete();
|
||||
|
||||
return redirect()->route('alerts.index')
|
||||
->with('success', 'Alert deleted.');
|
||||
}
|
||||
|
||||
public function deleteRead()
|
||||
{
|
||||
auth()->user()->notifications()
|
||||
->where('is_read', true)
|
||||
->delete();
|
||||
|
||||
return redirect()->route('alerts.index')
|
||||
->with('success', 'All read alerts deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\ConfirmsPasswords;
|
||||
|
||||
class ConfirmPasswordController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Confirm Password Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller is responsible for handling password confirmations and
|
||||
| uses a simple trait to include the behavior. You're free to explore
|
||||
| this trait and override any functions that require customization.
|
||||
|
|
||||
*/
|
||||
|
||||
use ConfirmsPasswords;
|
||||
|
||||
/**
|
||||
* Where to redirect users when the intended url fails.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = '/home';
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Reset Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller is responsible for handling password reset emails and
|
||||
| includes a trait which assists in sending these notifications from
|
||||
| your application to your users. Feel free to explore this trait.
|
||||
|
|
||||
*/
|
||||
|
||||
use SendsPasswordResetEmails;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Login Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller handles authenticating users for the application and
|
||||
| redirecting them to your home screen. The controller uses a trait
|
||||
| to conveniently provide its functionality to your applications.
|
||||
|
|
||||
*/
|
||||
|
||||
use AuthenticatesUsers;
|
||||
|
||||
/**
|
||||
* Where to redirect users after login.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = '/dashboard';
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('guest')->except('logout');
|
||||
$this->middleware('auth')->only('logout');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Auth\RegistersUsers;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller handles the registration of new users as well as their
|
||||
| validation and creation. By default this controller uses a trait to
|
||||
| provide this functionality without requiring any additional code.
|
||||
|
|
||||
*/
|
||||
|
||||
use RegistersUsers;
|
||||
|
||||
/**
|
||||
* Where to redirect users after registration.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = '/dashboard';
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('guest');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a validator for an incoming registration request.
|
||||
*
|
||||
* @param array $data
|
||||
* @return \Illuminate\Contracts\Validation\Validator
|
||||
*/
|
||||
protected function validator(array $data)
|
||||
{
|
||||
return Validator::make($data, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user instance after a valid registration.
|
||||
*
|
||||
* @param array $data
|
||||
* @return \App\Models\User
|
||||
*/
|
||||
protected function create(array $data)
|
||||
{
|
||||
return User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\ResetsPasswords;
|
||||
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Reset Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller is responsible for handling password reset requests
|
||||
| and uses a simple trait to include this behavior. You're free to
|
||||
| explore this trait and override any methods you wish to tweak.
|
||||
|
|
||||
*/
|
||||
|
||||
use ResetsPasswords;
|
||||
|
||||
/**
|
||||
* Where to redirect users after resetting their password.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = '/dashboard';
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\VerifiesEmails;
|
||||
|
||||
class VerificationController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Email Verification Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller is responsible for handling email verification for any
|
||||
| user that recently registered with the application. Emails may also
|
||||
| be re-sent if the user didn't receive the original email message.
|
||||
|
|
||||
*/
|
||||
|
||||
use VerifiesEmails;
|
||||
|
||||
/**
|
||||
* Where to redirect users after verification.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = '/dashboard';
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
$this->middleware('signed')->only('verify');
|
||||
$this->middleware('throttle:6,1')->only('verify', 'resend');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
class Controller extends BaseController
|
||||
{
|
||||
//
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Site;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$sites = auth()->user()->sites()
|
||||
->with(['uptimeChecks' => function($query) {
|
||||
$query->latest('checked_at')->limit(1);
|
||||
}, 'sslCertificates' => function($query) {
|
||||
$query->latest('checked_at')->limit(1);
|
||||
}])
|
||||
->withCount('brokenLinks')
|
||||
->get();
|
||||
|
||||
// Calculate stats for each site
|
||||
$sites->each(function($site) {
|
||||
$checks = $site->uptimeChecks()
|
||||
->where('checked_at', '>=', now()->subDays(30))
|
||||
->get();
|
||||
|
||||
$totalChecks = $checks->count();
|
||||
$successfulChecks = $checks->where('status_code', '>=', 200)
|
||||
->where('status_code', '<', 400)
|
||||
->count();
|
||||
|
||||
$site->uptime_percentage = $totalChecks > 0 ? ($successfulChecks / $totalChecks) * 100 : 0;
|
||||
$site->avg_response_time = $checks->avg('response_time_ms');
|
||||
});
|
||||
|
||||
// Calculate overall stats
|
||||
$totalSites = $sites->count();
|
||||
$sitesUp = $sites->filter(function($site) {
|
||||
$latestCheck = $site->uptimeChecks->first();
|
||||
return $latestCheck && $latestCheck->status_code >= 200 && $latestCheck->status_code < 400;
|
||||
})->count();
|
||||
$sitesDown = $sites->filter(function($site) {
|
||||
$latestCheck = $site->uptimeChecks->first();
|
||||
return $latestCheck && ($latestCheck->status_code < 200 || $latestCheck->status_code >= 400);
|
||||
})->count();
|
||||
$avgUptime = $sites->avg('uptime_percentage') ?? 0;
|
||||
|
||||
// Prepare chart data for last 7 days
|
||||
$chartData = null;
|
||||
if ($sites->isNotEmpty()) {
|
||||
$labels = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$labels[] = now()->subDays($i)->format('M d');
|
||||
}
|
||||
|
||||
$datasets = $sites->map(function($site) use ($labels) {
|
||||
$data = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$date = now()->subDays($i)->startOfDay();
|
||||
$checks = $site->uptimeChecks()
|
||||
->whereDate('checked_at', $date)
|
||||
->get();
|
||||
|
||||
$totalChecks = $checks->count();
|
||||
$successfulChecks = $checks->where('status_code', '>=', 200)
|
||||
->where('status_code', '<', 400)
|
||||
->count();
|
||||
|
||||
$data[] = $totalChecks > 0 ? round(($successfulChecks / $totalChecks) * 100, 2) : null;
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => $site->name,
|
||||
'data' => $data,
|
||||
'color' => '#' . substr(md5($site->id), 0, 6)
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$chartData = [
|
||||
'labels' => $labels,
|
||||
'datasets' => $datasets
|
||||
];
|
||||
}
|
||||
|
||||
return view('dashboard', compact(
|
||||
'sites',
|
||||
'totalSites',
|
||||
'sitesUp',
|
||||
'sitesDown',
|
||||
'avgUptime',
|
||||
'chartData'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Site $site)
|
||||
{
|
||||
$this->authorize('view', $site);
|
||||
|
||||
// Get uptime checks for the last 30 days
|
||||
$uptimeChecks = $site->uptimeChecks()
|
||||
->where('checked_at', '>=', now()->subDays(30))
|
||||
->orderBy('checked_at', 'asc')
|
||||
->get();
|
||||
|
||||
// Get latest SSL certificate
|
||||
$latestSslCert = $site->sslCertificates()
|
||||
->latest('checked_at')
|
||||
->first();
|
||||
|
||||
// Get broken links
|
||||
$brokenLinks = $site->brokenLinks()
|
||||
->orderBy('last_checked_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
// Calculate uptime percentage
|
||||
$totalChecks = $uptimeChecks->count();
|
||||
$successfulChecks = $uptimeChecks->where('status_code', '>=', 200)
|
||||
->where('status_code', '<', 400)
|
||||
->count();
|
||||
$uptimePercentage = $totalChecks > 0 ? round(($successfulChecks / $totalChecks) * 100, 2) : 0;
|
||||
|
||||
// Calculate average response time
|
||||
$avgResponseTime = $uptimeChecks->avg('response_time_ms');
|
||||
|
||||
// Prepare chart data
|
||||
$chartLabels = $uptimeChecks->pluck('checked_at')->map(fn($date) => $date->format('M d H:i'))->toArray();
|
||||
$chartData = $uptimeChecks->pluck('response_time_ms')->toArray();
|
||||
$chartStatusCodes = $uptimeChecks->pluck('status_code')->toArray();
|
||||
|
||||
return view('dashboard.show', compact(
|
||||
'site',
|
||||
'uptimeChecks',
|
||||
'latestSslCert',
|
||||
'brokenLinks',
|
||||
'uptimePercentage',
|
||||
'avgResponseTime',
|
||||
'chartLabels',
|
||||
'chartData',
|
||||
'chartStatusCodes'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the application dashboard.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Support\Renderable
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('home');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$preferences = auth()->user()->notification_preferences ?? [
|
||||
'uptime' => ['mail'],
|
||||
'ssl' => ['mail'],
|
||||
'broken_links' => ['mail']
|
||||
];
|
||||
|
||||
$slackWebhook = env('SLACK_WEBHOOK_URL', '');
|
||||
|
||||
return view('settings', compact('preferences', 'slackWebhook'));
|
||||
}
|
||||
|
||||
public function updateNotifications(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'uptime' => 'nullable|array',
|
||||
'uptime.*' => 'in:mail,slack',
|
||||
'ssl' => 'nullable|array',
|
||||
'ssl.*' => 'in:mail,slack',
|
||||
'broken_links' => 'nullable|array',
|
||||
'broken_links.*' => 'in:mail,slack',
|
||||
]);
|
||||
|
||||
$preferences = [
|
||||
'uptime' => $request->input('uptime', []),
|
||||
'ssl' => $request->input('ssl', []),
|
||||
'broken_links' => $request->input('broken_links', []),
|
||||
];
|
||||
|
||||
auth()->user()->update([
|
||||
'notification_preferences' => $preferences
|
||||
]);
|
||||
|
||||
return redirect()->route('settings')
|
||||
->with('success', 'Notification preferences updated successfully.');
|
||||
}
|
||||
|
||||
public function updateSlack(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'slack_webhook' => 'nullable|url'
|
||||
]);
|
||||
|
||||
// Note: In a production app, you would store this in the user's settings or env
|
||||
// For now, we'll just return a message
|
||||
return redirect()->route('settings')
|
||||
->with('success', 'Slack webhook URL saved. Please add it to your .env file as SLACK_WEBHOOK_URL.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Site;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class SiteController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$sites = Auth::user()->sites()
|
||||
->withCount(['uptimeChecks', 'sslCertificates', 'brokenLinks'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Get latest uptime check for each site
|
||||
$sites->each(function ($site) {
|
||||
$site->latestUptimeCheck = $site->uptimeChecks()->latest('checked_at')->first();
|
||||
$site->latestSslCertificate = $site->sslCertificates()->latest('checked_at')->first();
|
||||
});
|
||||
|
||||
return view('sites.index', compact('sites'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('sites.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'url' => 'required|url|max:255',
|
||||
'monitoring_enabled' => 'sometimes|nullable|in:on,off',
|
||||
]);
|
||||
|
||||
$site = Auth::user()->sites()->create([
|
||||
'name' => $validated['name'],
|
||||
'url' => $validated['url'],
|
||||
'monitoring_enabled' => $request->has('monitoring_enabled'),
|
||||
]);
|
||||
|
||||
return redirect()->route('sites.index')
|
||||
->with('success', 'Site added successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Site $site)
|
||||
{
|
||||
$this->authorize('view', $site);
|
||||
|
||||
return redirect()->route('dashboard.show', $site);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(Site $site)
|
||||
{
|
||||
$this->authorize('update', $site);
|
||||
|
||||
return view('sites.edit', compact('site'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Site $site)
|
||||
{
|
||||
$this->authorize('update', $site);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'url' => 'required|url|max:255',
|
||||
'monitoring_enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$site->update([
|
||||
'name' => $validated['name'],
|
||||
'url' => $validated['url'],
|
||||
'monitoring_enabled' => $request->has('monitoring_enabled'),
|
||||
]);
|
||||
|
||||
return redirect()->route('sites.index')
|
||||
->with('success', 'Site updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Site $site)
|
||||
{
|
||||
$this->authorize('delete', $site);
|
||||
|
||||
$site->delete();
|
||||
|
||||
return redirect()->route('sites.index')
|
||||
->with('success', 'Site deleted successfully!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BrokenLink;
|
||||
use App\Models\Site;
|
||||
use App\Notifications\BrokenLinkAlert;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Spatie\Crawler\Crawler;
|
||||
use Spatie\Crawler\CrawlObservers\CrawlObserver;
|
||||
|
||||
class CheckBrokenLinks implements ShouldQueue
|
||||
{
|
||||
use Queueable, Batchable;
|
||||
|
||||
public $timeout = 300; // 5 minutes timeout for crawling
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Site $site
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
Log::info("Starting broken link check for site {$this->site->id} ({$this->site->url})");
|
||||
|
||||
// Parse the base URL to get the domain
|
||||
$parsedUrl = parse_url($this->site->url);
|
||||
|
||||
if (!isset($parsedUrl['host'])) {
|
||||
Log::warning("Invalid URL for broken link check on site {$this->site->id}: {$this->site->url}");
|
||||
return;
|
||||
}
|
||||
|
||||
$baseDomain = $parsedUrl['host'];
|
||||
|
||||
// Create a crawler observer to handle broken links
|
||||
$observer = new class($this->site, $baseDomain) extends CrawlObserver {
|
||||
private Site $site;
|
||||
private string $baseDomain;
|
||||
|
||||
public function __construct(Site $site, string $baseDomain)
|
||||
{
|
||||
$this->site = $site;
|
||||
$this->baseDomain = $baseDomain;
|
||||
}
|
||||
|
||||
public function crawled(
|
||||
UriInterface $url,
|
||||
ResponseInterface $response,
|
||||
?UriInterface $foundOnUrl = null,
|
||||
?string $linkText = null
|
||||
): void {
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
// Check if the link is broken (4xx or 5xx)
|
||||
if ($statusCode >= 400) {
|
||||
// Only log internal links (same domain)
|
||||
$linkHost = $url->getHost();
|
||||
|
||||
if ($linkHost === $this->baseDomain) {
|
||||
BrokenLink::updateOrCreate(
|
||||
[
|
||||
'site_id' => $this->site->id,
|
||||
'url' => (string) $url,
|
||||
],
|
||||
[
|
||||
'status_code' => $statusCode,
|
||||
'last_checked_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
Log::info("Broken link detected on site {$this->site->id}: {$url} (HTTP {$statusCode})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function crawlFailed(
|
||||
UriInterface $url,
|
||||
RequestException $requestException,
|
||||
?UriInterface $foundOnUrl = null,
|
||||
?string $linkText = null
|
||||
): void {
|
||||
// Only log internal links (same domain)
|
||||
$linkHost = $url->getHost();
|
||||
|
||||
if ($linkHost === $this->baseDomain) {
|
||||
$statusCode = $requestException->getResponse()
|
||||
? $requestException->getResponse()->getStatusCode()
|
||||
: null;
|
||||
|
||||
BrokenLink::updateOrCreate(
|
||||
[
|
||||
'site_id' => $this->site->id,
|
||||
'url' => (string) $url,
|
||||
],
|
||||
[
|
||||
'status_code' => $statusCode,
|
||||
'last_checked_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
Log::info("Crawl failed for site {$this->site->id}: {$url}");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Configure and start the crawler
|
||||
Crawler::create()
|
||||
->setConcurrency(3)
|
||||
->setMaximumDepth(3)
|
||||
->setMaximumCrawlCount(50)
|
||||
->ignoreRobots()
|
||||
->acceptNofollowLinks()
|
||||
->setCrawlObserver($observer)
|
||||
->startCrawling($this->site->url);
|
||||
|
||||
Log::info("Completed broken link check for site {$this->site->id}");
|
||||
|
||||
// Get all broken links for this site from current scan
|
||||
$brokenLinks = BrokenLink::where('site_id', $this->site->id)
|
||||
->where('last_checked_at', '>=', now()->subMinutes(10))
|
||||
->get()
|
||||
->map(fn($link) => [
|
||||
'url' => $link->url,
|
||||
'status_code' => $link->status_code,
|
||||
])
|
||||
->toArray();
|
||||
|
||||
// Send notification if broken links were found
|
||||
if (!empty($brokenLinks)) {
|
||||
$this->site->user->notify(new BrokenLinkAlert(
|
||||
$this->site,
|
||||
$brokenLinks
|
||||
));
|
||||
|
||||
Log::info("Broken link alert sent for site {$this->site->id}: " . count($brokenLinks) . " broken links found");
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Broken link check failed for site {$this->site->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Notification;
|
||||
use App\Models\Site;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Notifications\SslExpiryAlert;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CheckSslCertificate implements ShouldQueue
|
||||
{
|
||||
use Queueable, Batchable;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Site $site
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
// Parse URL to get host
|
||||
$parsedUrl = parse_url($this->site->url);
|
||||
|
||||
if (!isset($parsedUrl['host'])) {
|
||||
Log::warning("Invalid URL for SSL check on site {$this->site->id}: {$this->site->url}");
|
||||
return;
|
||||
}
|
||||
|
||||
$host = $parsedUrl['host'];
|
||||
$port = $parsedUrl['scheme'] === 'https' ? 443 : 443;
|
||||
|
||||
// Create SSL context
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'capture_peer_cert' => true,
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
// Connect to the server and get SSL certificate
|
||||
$client = @stream_socket_client(
|
||||
"ssl://{$host}:{$port}",
|
||||
$errno,
|
||||
$errstr,
|
||||
10,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$context
|
||||
);
|
||||
|
||||
if ($client === false) {
|
||||
Log::warning("Failed to connect to {$host}:{$port} for SSL check: {$errstr}");
|
||||
return;
|
||||
}
|
||||
|
||||
$params = stream_context_get_params($client);
|
||||
fclose($client);
|
||||
|
||||
if (!isset($params['options']['ssl']['peer_certificate'])) {
|
||||
Log::warning("No SSL certificate found for site {$this->site->id}");
|
||||
return;
|
||||
}
|
||||
|
||||
$cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']);
|
||||
|
||||
if ($cert === false) {
|
||||
Log::warning("Failed to parse SSL certificate for site {$this->site->id}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract certificate information
|
||||
$issuer = $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? 'Unknown';
|
||||
$expiresAt = Carbon::createFromTimestamp($cert['validTo_time_t']);
|
||||
$checkedAt = now();
|
||||
|
||||
// Store the SSL certificate check result
|
||||
SslCertificate::create([
|
||||
'site_id' => $this->site->id,
|
||||
'issuer' => $issuer,
|
||||
'expires_at' => $expiresAt,
|
||||
'checked_at' => $checkedAt,
|
||||
]);
|
||||
|
||||
// Check if certificate expires within 30 days
|
||||
$daysUntilExpiry = $checkedAt->diffInDays($expiresAt, false);
|
||||
|
||||
if ($daysUntilExpiry < 30 && $daysUntilExpiry >= 0) {
|
||||
// Send notification to user
|
||||
$this->site->user->notify(new SslExpiryAlert(
|
||||
$this->site,
|
||||
$issuer,
|
||||
$expiresAt,
|
||||
(int) $daysUntilExpiry,
|
||||
false
|
||||
));
|
||||
|
||||
Log::warning("SSL certificate expiring soon for site {$this->site->id}: {$daysUntilExpiry} days remaining");
|
||||
} elseif ($daysUntilExpiry < 0) {
|
||||
// Certificate already expired
|
||||
$this->site->user->notify(new SslExpiryAlert(
|
||||
$this->site,
|
||||
$issuer,
|
||||
$expiresAt,
|
||||
(int) $daysUntilExpiry,
|
||||
true
|
||||
));
|
||||
|
||||
Log::error("SSL certificate expired for site {$this->site->id}");
|
||||
}
|
||||
|
||||
Log::info("SSL check completed for site {$this->site->id} ({$this->site->url}): Issuer={$issuer}, Expires={$expiresAt->format('Y-m-d')}");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("SSL check failed for site {$this->site->id}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Site;
|
||||
use App\Models\UptimeCheck;
|
||||
use App\Notifications\UptimeAlert;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Illuminate\Bus\Batchable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class CheckUptime implements ShouldQueue
|
||||
{
|
||||
use Queueable, Batchable;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Site $site
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$client = new Client([
|
||||
'timeout' => 5,
|
||||
'connect_timeout' => 5,
|
||||
'allow_redirects' => [
|
||||
'max' => 5,
|
||||
'track_redirects' => true,
|
||||
],
|
||||
'http_errors' => false, // Don't throw exceptions on 4xx/5xx responses
|
||||
]);
|
||||
|
||||
$statusCode = null;
|
||||
$responseTime = null;
|
||||
$startTime = microtime(true);
|
||||
|
||||
|
||||
try {
|
||||
// Validate URL format
|
||||
if (!filter_var($this->site->url, FILTER_VALIDATE_URL)) {
|
||||
Log::warning("Invalid URL format for site {$this->site->id}: {$this->site->url}");
|
||||
$statusCode = null;
|
||||
$responseTime = null;
|
||||
|
||||
} else {
|
||||
// Send HEAD request for faster checks
|
||||
$response = $client->head($this->site->url);
|
||||
|
||||
$endTime = microtime(true);
|
||||
$responseTime = (int) round(($endTime - $startTime) * 1000); // Convert to milliseconds
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
}
|
||||
} catch (ConnectException $e) {
|
||||
// Connection timeout or DNS failure
|
||||
Log::info("Connection failed for site {$this->site->id}: {$e->getMessage()}");
|
||||
$statusCode = null;
|
||||
$responseTime = null;
|
||||
|
||||
} catch (RequestException $e) {
|
||||
// HTTP error occurred
|
||||
$endTime = microtime(true);
|
||||
$responseTime = (int) round(($endTime - $startTime) * 1000);
|
||||
$statusCode = $e->getResponse() ? $e->getResponse()->getStatusCode() : null;
|
||||
|
||||
Log::info("Request failed for site {$this->site->id}: HTTP {$statusCode}");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Any other exception
|
||||
Log::error("Unexpected error checking site {$this->site->id}: {$e->getMessage()}");
|
||||
$statusCode = null;
|
||||
$responseTime = null;
|
||||
|
||||
}
|
||||
|
||||
// Store the uptime check result
|
||||
UptimeCheck::create([
|
||||
'site_id' => $this->site->id,
|
||||
'status_code' => $statusCode,
|
||||
'response_time_ms' => $responseTime,
|
||||
'checked_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
// Send notification if site is down (status >= 400 or unreachable)
|
||||
if ($statusCode === null || $statusCode >= 400) {
|
||||
$this->site->user->notify(new UptimeAlert(
|
||||
$this->site,
|
||||
$statusCode,
|
||||
$responseTime
|
||||
));
|
||||
|
||||
Log::warning("Downtime alert sent for site {$this->site->id}: HTTP {$statusCode}");
|
||||
}
|
||||
Log::info("Uptime check completed for site {$this->site->id} ({$this->site->url}): HTTP {$statusCode}, {$responseTime}ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BrokenLink extends Model
|
||||
{
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'site_id',
|
||||
'url',
|
||||
'status_code',
|
||||
'last_checked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function site(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Site::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Notification extends Model
|
||||
{
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'type',
|
||||
'message',
|
||||
'is_read',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_read' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Site extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'url',
|
||||
'name',
|
||||
'monitoring_enabled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'monitoring_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function uptimeChecks(): HasMany
|
||||
{
|
||||
return $this->hasMany(UptimeCheck::class);
|
||||
}
|
||||
|
||||
public function sslCertificates(): HasMany
|
||||
{
|
||||
return $this->hasMany(SslCertificate::class);
|
||||
}
|
||||
|
||||
public function brokenLinks(): HasMany
|
||||
{
|
||||
return $this->hasMany(BrokenLink::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SslCertificate extends Model
|
||||
{
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'site_id',
|
||||
'issuer',
|
||||
'expires_at',
|
||||
'checked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function site(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Site::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UptimeCheck extends Model
|
||||
{
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'site_id',
|
||||
'status_code',
|
||||
'response_time_ms',
|
||||
'checked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function site(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Site::class);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
@@ -21,6 +22,7 @@ class User extends Authenticatable
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'notification_preferences',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -43,6 +45,17 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'notification_preferences' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function sites(): HasMany
|
||||
{
|
||||
return $this->hasMany(Site::class);
|
||||
}
|
||||
|
||||
public function notifications(): HasMany
|
||||
{
|
||||
return $this->hasMany(Notification::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Site;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BrokenLinkAlert extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Site $site,
|
||||
public array $brokenLinks
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
$preferences = $notifiable->notification_preferences ?? [];
|
||||
$channels = $preferences['broken_links'] ?? ['mail'];
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$brokenLinkCount = count($this->brokenLinks);
|
||||
|
||||
$message = (new MailMessage)
|
||||
->warning()
|
||||
->subject("⚠️ Broken Links Detected: {$this->site->name}")
|
||||
->line("We detected **{$brokenLinkCount}** broken link(s) on **{$this->site->name}**.")
|
||||
->line("Site URL: {$this->site->url}")
|
||||
->line('')
|
||||
->line('**Broken Links:**');
|
||||
|
||||
// Add up to 10 broken links to the email
|
||||
$displayLimit = min(10, $brokenLinkCount);
|
||||
foreach (array_slice($this->brokenLinks, 0, $displayLimit) as $link) {
|
||||
$message->line("• {$link['url']} (HTTP {$link['status_code']})");
|
||||
}
|
||||
|
||||
if ($brokenLinkCount > $displayLimit) {
|
||||
$remaining = $brokenLinkCount - $displayLimit;
|
||||
$message->line("... and {$remaining} more");
|
||||
}
|
||||
|
||||
$message->line('')
|
||||
->action('View All Broken Links', url("/sites/{$this->site->id}/broken-links"))
|
||||
->line('Please fix these broken links to improve user experience and SEO.');
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'site_id' => $this->site->id,
|
||||
'site_name' => $this->site->name,
|
||||
'site_url' => $this->site->url,
|
||||
'broken_link_count' => count($this->brokenLinks),
|
||||
'broken_links' => $this->brokenLinks,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Site;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Messages\SlackMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class SslExpiryAlert extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Site $site,
|
||||
public string $issuer,
|
||||
public Carbon $expiresAt,
|
||||
public int $daysRemaining,
|
||||
public bool $isExpired = false
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
$preferences = $notifiable->notification_preferences ?? [];
|
||||
$channels = $preferences['ssl'] ?? ['mail'];
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$message = (new MailMessage)
|
||||
->subject($this->isExpired
|
||||
? "🔴 SSL Certificate Expired: {$this->site->name}"
|
||||
: "⚠️ SSL Certificate Expiring Soon: {$this->site->name}")
|
||||
->line($this->isExpired
|
||||
? "The SSL certificate for **{$this->site->name}** has **expired**."
|
||||
: "The SSL certificate for **{$this->site->name}** is expiring soon.")
|
||||
->line("URL: {$this->site->url}")
|
||||
->line("Issuer: {$this->issuer}")
|
||||
->line($this->isExpired
|
||||
? "Expired on: {$this->expiresAt->format('Y-m-d')}"
|
||||
: "Expires on: {$this->expiresAt->format('Y-m-d')} ({$this->daysRemaining} days remaining)")
|
||||
->action('View Site Details', url("/sites/{$this->site->id}"));
|
||||
|
||||
if ($this->isExpired) {
|
||||
$message->error();
|
||||
$message->line('⚠️ Your site visitors may see security warnings. Please renew the certificate immediately.');
|
||||
} else {
|
||||
$message->warning();
|
||||
$message->line('Please renew the certificate before it expires to avoid service disruption.');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Slack representation of the notification.
|
||||
*/
|
||||
public function toSlack(object $notifiable): SlackMessage
|
||||
{
|
||||
$color = $this->isExpired ? 'danger' : 'warning';
|
||||
$emoji = $this->isExpired ? '🔴' : '⚠️';
|
||||
|
||||
return (new SlackMessage)
|
||||
->from('Site Monitor')
|
||||
->to(config('services.slack.channel'))
|
||||
->content($this->isExpired
|
||||
? "{$emoji} SSL Certificate Expired!"
|
||||
: "{$emoji} SSL Certificate Expiring Soon!")
|
||||
->attachment(function ($attachment) use ($color) {
|
||||
$attachment
|
||||
->color($color)
|
||||
->title($this->site->name, url("/sites/{$this->site->id}"))
|
||||
->fields([
|
||||
'URL' => $this->site->url,
|
||||
'Issuer' => $this->issuer,
|
||||
'Expires' => $this->expiresAt->format('Y-m-d'),
|
||||
'Status' => $this->isExpired
|
||||
? 'EXPIRED'
|
||||
: "{$this->daysRemaining} days remaining",
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'site_id' => $this->site->id,
|
||||
'site_name' => $this->site->name,
|
||||
'site_url' => $this->site->url,
|
||||
'issuer' => $this->issuer,
|
||||
'expires_at' => $this->expiresAt->toDateTimeString(),
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
'is_expired' => $this->isExpired,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Site;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class UptimeAlert extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public Site $site,
|
||||
public ?int $statusCode,
|
||||
public ?int $responseTime
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
$preferences = $notifiable->notification_preferences ?? [];
|
||||
$channels = $preferences['uptime'] ?? ['mail'];
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$status = $this->statusCode ? "returned HTTP {$this->statusCode}" : "is unreachable";
|
||||
|
||||
return (new MailMessage)
|
||||
->error()
|
||||
->subject("⚠️ Site Down Alert: {$this->site->name}")
|
||||
->line("Your site **{$this->site->name}** is experiencing downtime.")
|
||||
->line("URL: {$this->site->url}")
|
||||
->line("Status: {$status}")
|
||||
->line("Response Time: " . ($this->responseTime ? "{$this->responseTime}ms" : "N/A"))
|
||||
->line("Detected at: " . now()->format('Y-m-d H:i:s'))
|
||||
->action('View Site Details', url("/sites/{$this->site->id}"))
|
||||
->line('Please investigate the issue as soon as possible.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'site_id' => $this->site->id,
|
||||
'site_name' => $this->site->name,
|
||||
'site_url' => $this->site->url,
|
||||
'status_code' => $this->statusCode,
|
||||
'response_time' => $this->responseTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class NotificationPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Notification $notification): bool
|
||||
{
|
||||
return $user->id === $notification->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Notification $notification): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Notification $notification): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, Notification $notification): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, Notification $notification): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Site;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class SitePolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Site $site): bool
|
||||
{
|
||||
return $user->id === $site->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Site $site): bool
|
||||
{
|
||||
return $user->id === $site->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Site $site): bool
|
||||
{
|
||||
return $user->id === $site->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, Site $site): bool
|
||||
{
|
||||
return $user->id === $site->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, Site $site): bool
|
||||
{
|
||||
return $user->id === $site->user_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
services:
|
||||
laravel.test:
|
||||
build:
|
||||
context: './vendor/laravel/sail/runtimes/8.5'
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP}'
|
||||
image: 'sail-8.5/app'
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
ports:
|
||||
- '${APP_PORT:-80}:80'
|
||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER}'
|
||||
LARAVEL_SAIL: 1
|
||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||
volumes:
|
||||
- '.:/var/www/html'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- mysql
|
||||
mysql:
|
||||
image: 'mysql:8.4'
|
||||
ports:
|
||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
|
||||
MYSQL_ROOT_HOST: '%'
|
||||
MYSQL_DATABASE: '${DB_DATABASE}'
|
||||
MYSQL_USER: '${DB_USERNAME}'
|
||||
MYSQL_PASSWORD: '${DB_PASSWORD}'
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
||||
MYSQL_EXTRA_OPTIONS: '${MYSQL_EXTRA_OPTIONS:-}'
|
||||
volumes:
|
||||
- 'sail-mysql:/var/lib/mysql'
|
||||
- './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- mysqladmin
|
||||
- ping
|
||||
- '-p${DB_PASSWORD}'
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
networks:
|
||||
sail:
|
||||
driver: bridge
|
||||
volumes:
|
||||
sail-mysql:
|
||||
driver: local
|
||||
+4
-1
@@ -10,8 +10,11 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"guzzlehttp/guzzle": "^7.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"laravel/ui": "^4.6",
|
||||
"spatie/crawler": "^8.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
Generated
+445
-1
@@ -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": "8078c81fbb23f3fc37578b2651f04cc5",
|
||||
"content-hash": "d9587b74374c571fcfc09447a91fcb15",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -1460,6 +1460,69 @@
|
||||
},
|
||||
"time": "2025-12-19T19:16:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/ui",
|
||||
"version": "v4.6.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/ui.git",
|
||||
"reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/ui/zipball/7d6ffa38d79f19c9b3e70a751a9af845e8f41d88",
|
||||
"reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/console": "^9.21|^10.0|^11.0|^12.0",
|
||||
"illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0",
|
||||
"illuminate/support": "^9.21|^10.0|^11.0|^12.0",
|
||||
"illuminate/validation": "^9.21|^10.0|^11.0|^12.0",
|
||||
"php": "^8.0",
|
||||
"symfony/console": "^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "^7.35|^8.15|^9.0|^10.0",
|
||||
"phpunit/phpunit": "^9.3|^10.4|^11.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Ui\\UiServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "4.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Ui\\": "src/",
|
||||
"Illuminate\\Foundation\\Auth\\": "auth-backend/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel UI utilities and presets.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"ui"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/laravel/ui/tree/v4.6.1"
|
||||
},
|
||||
"time": "2025-01-28T15:15:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.8.0",
|
||||
@@ -2381,6 +2444,60 @@
|
||||
},
|
||||
"time": "2025-12-22T12:14:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nicmart/tree",
|
||||
"version": "0.10.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nicmart/Tree.git",
|
||||
"reference": "2ef11e329d26005ef49dbacd0223bcfd2515b6cc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nicmart/Tree/zipball/2ef11e329d26005ef49dbacd0223bcfd2515b6cc",
|
||||
"reference": "2ef11e329d26005ef49dbacd0223bcfd2515b6cc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ergebnis/composer-normalize": "^2.48.2",
|
||||
"ergebnis/license": "^2.7.0",
|
||||
"ergebnis/php-cs-fixer-config": "^6.28.1",
|
||||
"fakerphp/faker": "^1.24.1",
|
||||
"infection/infection": "~0.26.19",
|
||||
"phpunit/phpunit": "^9.6.19",
|
||||
"psalm/plugin-phpunit": "~0.19.0",
|
||||
"vimeo/psalm": "^5.26.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Tree\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolò Martini",
|
||||
"email": "nicmartnic@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Andreas Möller",
|
||||
"email": "am@localheinz.com"
|
||||
}
|
||||
],
|
||||
"description": "A basic but flexible php tree data structure and a fluent tree builder implementation.",
|
||||
"support": {
|
||||
"issues": "https://github.com/nicmart/Tree/issues",
|
||||
"source": "https://github.com/nicmart/Tree/tree/0.10.1"
|
||||
},
|
||||
"time": "2025-11-25T08:51:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
"version": "v5.7.0",
|
||||
@@ -3290,6 +3407,263 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/browsershot",
|
||||
"version": "5.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/browsershot.git",
|
||||
"reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/browsershot/zipball/9bc6b8d67175810d7a399b2588c3401efe2d02a8",
|
||||
"reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"ext-json": "*",
|
||||
"php": "^8.2",
|
||||
"spatie/temporary-directory": "^2.0",
|
||||
"symfony/process": "^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^3.0|^4.0",
|
||||
"spatie/image": "^3.6",
|
||||
"spatie/pdf-to-text": "^1.52",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Browsershot\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://github.com/freekmurze",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Convert a webpage to an image or pdf using headless Chrome",
|
||||
"homepage": "https://github.com/spatie/browsershot",
|
||||
"keywords": [
|
||||
"chrome",
|
||||
"convert",
|
||||
"headless",
|
||||
"image",
|
||||
"pdf",
|
||||
"puppeteer",
|
||||
"screenshot",
|
||||
"webpage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/spatie/browsershot/tree/5.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-22T10:02:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/crawler",
|
||||
"version": "8.4.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/crawler.git",
|
||||
"reference": "67cbd569437d0e35b1332c5f21d009cac8b4a37b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/crawler/zipball/67cbd569437d0e35b1332c5f21d009cac8b4a37b",
|
||||
"reference": "67cbd569437d0e35b1332c5f21d009cac8b4a37b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/guzzle": "^7.3",
|
||||
"guzzlehttp/psr7": "^2.0",
|
||||
"illuminate/collections": "^10.0|^11.0|^12.0",
|
||||
"nicmart/tree": "^0.10",
|
||||
"php": "^8.2",
|
||||
"spatie/browsershot": "^5.0.5",
|
||||
"spatie/robots-txt": "^2.0",
|
||||
"symfony/dom-crawler": "^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^2.0|^3.0",
|
||||
"spatie/ray": "^1.37"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Crawler\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be"
|
||||
}
|
||||
],
|
||||
"description": "Crawl all internal links found on a website",
|
||||
"homepage": "https://github.com/spatie/crawler",
|
||||
"keywords": [
|
||||
"crawler",
|
||||
"link",
|
||||
"spatie",
|
||||
"website"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/crawler/issues",
|
||||
"source": "https://github.com/spatie/crawler/tree/8.4.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-26T17:35:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/robots-txt",
|
||||
"version": "2.5.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/robots-txt.git",
|
||||
"reference": "edb91c798ec70583d41c131019da45fa167af5e8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/robots-txt/zipball/edb91c798ec70583d41c131019da45fa167af5e8",
|
||||
"reference": "edb91c798ec70583d41c131019da45fa167af5e8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Robots\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Brent Roose",
|
||||
"email": "brent@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Determine if a page may be crawled from robots.txt and robots meta tags",
|
||||
"homepage": "https://github.com/spatie/robots-txt",
|
||||
"keywords": [
|
||||
"robots-txt",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/robots-txt/issues",
|
||||
"source": "https://github.com/spatie/robots-txt/tree/2.5.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-20T13:00:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/temporary-directory",
|
||||
"version": "2.3.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/temporary-directory.git",
|
||||
"reference": "662e481d6ec07ef29fd05010433428851a42cd07"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/temporary-directory/zipball/662e481d6ec07ef29fd05010433428851a42cd07",
|
||||
"reference": "662e481d6ec07ef29fd05010433428851a42cd07",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\TemporaryDirectory\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alex Vanderbist",
|
||||
"email": "alex@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Easily create, use and destroy temporary directories",
|
||||
"homepage": "https://github.com/spatie/temporary-directory",
|
||||
"keywords": [
|
||||
"php",
|
||||
"spatie",
|
||||
"temporary-directory"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/temporary-directory/issues",
|
||||
"source": "https://github.com/spatie/temporary-directory/tree/2.3.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://spatie.be/open-source/support-us",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-12T07:42:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v8.0.0",
|
||||
@@ -3601,6 +3975,76 @@
|
||||
],
|
||||
"time": "2024-09-25T14:21:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dom-crawler",
|
||||
"version": "v8.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/dom-crawler.git",
|
||||
"reference": "11a3dbe6f6c0ae03ae71f879cf5c2e28db107179"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/11a3dbe6f6c0ae03ae71f879cf5c2e28db107179",
|
||||
"reference": "11a3dbe6f6c0ae03ae71f879cf5c2e28db107179",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/polyfill-ctype": "^1.8",
|
||||
"symfony/polyfill-mbstring": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/css-selector": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\DomCrawler\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Eases DOM navigation for HTML and XML documents",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/dom-crawler/tree/v8.0.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-06T17:00:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/error-handler",
|
||||
"version": "v7.4.0",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Site;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BrokenLink>
|
||||
*/
|
||||
class BrokenLinkFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$brokenStatusCodes = [404, 500, 503, 403, 410];
|
||||
|
||||
return [
|
||||
'site_id' => Site::factory(),
|
||||
'url' => fake()->url(),
|
||||
'status_code' => fake()->randomElement($brokenStatusCodes),
|
||||
'last_checked_at' => fake()->dateTimeBetween('-14 days', 'now'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Site>
|
||||
*/
|
||||
class SiteFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'url' => fake()->url(),
|
||||
'name' => fake()->company() . ' Website',
|
||||
'monitoring_enabled' => fake()->boolean(80), // 80% chance of being enabled
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Site;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SslCertificate>
|
||||
*/
|
||||
class SslCertificateFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$issuers = [
|
||||
"Let's Encrypt",
|
||||
'DigiCert Inc',
|
||||
'Google Trust Services',
|
||||
'Amazon',
|
||||
'Cloudflare Inc',
|
||||
];
|
||||
|
||||
return [
|
||||
'site_id' => Site::factory(),
|
||||
'issuer' => fake()->randomElement($issuers),
|
||||
'expires_at' => fake()->dateTimeBetween('now', '+365 days'),
|
||||
'checked_at' => fake()->dateTimeBetween('-7 days', 'now'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Site;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UptimeCheck>
|
||||
*/
|
||||
class UptimeCheckFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$statusCodes = [200, 200, 200, 200, 301, 302, 404, 500, 503]; // Weighted towards 200
|
||||
|
||||
return [
|
||||
'site_id' => Site::factory(),
|
||||
'status_code' => fake()->randomElement($statusCodes),
|
||||
'response_time_ms' => fake()->numberBetween(50, 5000),
|
||||
'checked_at' => fake()->dateTimeBetween('-30 days', 'now'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sites', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('url');
|
||||
$table->string('name');
|
||||
$table->boolean('monitoring_enabled')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes
|
||||
$table->index('user_id');
|
||||
$table->index('monitoring_enabled');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sites');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('uptime_checks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('site_id')->constrained()->onDelete('cascade');
|
||||
$table->integer('status_code')->nullable();
|
||||
$table->integer('response_time_ms')->nullable();
|
||||
$table->timestamp('checked_at');
|
||||
$table->timestamp('created_at');
|
||||
|
||||
// Indexes
|
||||
$table->index('site_id');
|
||||
$table->index('checked_at');
|
||||
$table->index(['site_id', 'checked_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('uptime_checks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ssl_certificates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('site_id')->constrained()->onDelete('cascade');
|
||||
$table->string('issuer');
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamp('checked_at');
|
||||
$table->timestamp('created_at');
|
||||
|
||||
// Indexes
|
||||
$table->index('site_id');
|
||||
$table->index('expires_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ssl_certificates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('broken_links', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('site_id')->constrained()->onDelete('cascade');
|
||||
$table->string('url');
|
||||
$table->integer('status_code')->nullable();
|
||||
$table->timestamp('last_checked_at');
|
||||
$table->timestamp('created_at');
|
||||
|
||||
// Indexes
|
||||
$table->index('site_id');
|
||||
$table->index('last_checked_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('broken_links');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('type');
|
||||
$table->text('message');
|
||||
$table->boolean('is_read')->default(false);
|
||||
$table->timestamp('created_at');
|
||||
|
||||
// Indexes
|
||||
$table->index('user_id');
|
||||
$table->index('is_read');
|
||||
$table->index(['user_id', 'is_read']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->json('notification_preferences')->nullable()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('notification_preferences');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,7 @@ class DatabaseSeeder extends Seeder
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Site;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class MonitoringDemoSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Create a demo user
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Demo User',
|
||||
'email' => 'demo@example.com',
|
||||
]);
|
||||
|
||||
// Create 10 demo sites for the user
|
||||
Site::factory(10)
|
||||
->for($user)
|
||||
->create()
|
||||
->each(function (Site $site) {
|
||||
// Create 5-15 uptime checks per site
|
||||
$site->uptimeChecks()->createMany(
|
||||
\App\Models\UptimeCheck::factory(rand(5, 15))->make()->toArray()
|
||||
);
|
||||
|
||||
// Create 1-3 SSL certificates per site
|
||||
$site->sslCertificates()->createMany(
|
||||
\App\Models\SslCertificate::factory(rand(1, 3))->make()->toArray()
|
||||
);
|
||||
|
||||
// Create 0-5 broken links per site (some sites may have none)
|
||||
$brokenLinksCount = rand(0, 5);
|
||||
if ($brokenLinksCount > 0) {
|
||||
$site->brokenLinks()->createMany(
|
||||
\App\Models\BrokenLink::factory($brokenLinksCount)->make()->toArray()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$this->command->info('Created 10 demo sites with monitoring data!');
|
||||
$this->command->info("Login with: {$user->email}");
|
||||
}
|
||||
}
|
||||
Generated
+2856
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,13 @@
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"bootstrap": "^5.2.3",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"sass": "^1.56.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.7"
|
||||
}
|
||||
|
||||
+1
-2
@@ -23,8 +23,7 @@
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_DATABASE" value="testing"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/* Additional custom styles for the monitoring dashboard */
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-online::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #10b981;
|
||||
margin-right: 8px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-down::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #ef4444;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top: 3px solid var(--indigo-600);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.dark::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.dark::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dark::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
/* Hover effects for cards */
|
||||
.card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Alert animations */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* Chart container */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
/* Responsive table improvements */
|
||||
@media (max-width: 768px) {
|
||||
.table-responsive {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.table-responsive table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge improvements */
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Button focus states */
|
||||
.btn:focus {
|
||||
outline: 2px solid var(--indigo-600);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Custom utilities */
|
||||
.text-truncate-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar hover effect */
|
||||
.sidebar-nav-link {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-nav-link::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
background-color: var(--indigo-600);
|
||||
transform: scaleY(0);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-nav-link:hover::before,
|
||||
.sidebar-nav-link.active::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
/* Notification dot */
|
||||
.notification-dot {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #ef4444;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Empty state illustrations */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Progress bars */
|
||||
.progress-thin {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* Card stat improvements */
|
||||
.card-stat {
|
||||
border-left: 4px solid var(--indigo-600);
|
||||
}
|
||||
|
||||
.card-stat.success {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.card-stat.danger {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.card-stat.warning {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Custom JavaScript for the monitoring dashboard
|
||||
*/
|
||||
|
||||
// Auto-dismiss alerts after 5 seconds
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const alerts = document.querySelectorAll('.alert-dismissible');
|
||||
alerts.forEach(alert => {
|
||||
setTimeout(() => {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
|
||||
// Copy to clipboard functionality
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast('Copied to clipboard!', 'success');
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
showToast('Failed to copy', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
function showToast(message, type = 'info') {
|
||||
const toastContainer = document.getElementById('toastContainer');
|
||||
if (!toastContainer) {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toastContainer';
|
||||
container.className = 'position-fixed bottom-0 end-0 p-3';
|
||||
container.style.zIndex = '9999';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-white bg-${type} border-0`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'assertive');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('toastContainer').appendChild(toast);
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
|
||||
toast.addEventListener('hidden.bs.toast', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh data without page reload
|
||||
function refreshStats() {
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.classList.add('disabled');
|
||||
refreshBtn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Refreshing...';
|
||||
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Format timestamps to relative time
|
||||
function formatRelativeTime() {
|
||||
const timeElements = document.querySelectorAll('[data-timestamp]');
|
||||
timeElements.forEach(element => {
|
||||
const timestamp = parseInt(element.getAttribute('data-timestamp'));
|
||||
const date = new Date(timestamp * 1000);
|
||||
element.textContent = getRelativeTime(date);
|
||||
});
|
||||
}
|
||||
|
||||
function getRelativeTime(date) {
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||
if (days < 30) return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Auto-refresh dashboard every 5 minutes
|
||||
let autoRefreshInterval;
|
||||
function startAutoRefresh(interval = 300000) { // 5 minutes default
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
}
|
||||
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
console.log('Auto-refreshing dashboard...');
|
||||
location.reload();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
// Stop auto-refresh
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// Ctrl/Cmd + K to focus search (if exists)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const searchInput = document.querySelector('[type="search"]');
|
||||
if (searchInput) searchInput.focus();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + N to create new site
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
const createBtn = document.querySelector('[href*="sites/create"]');
|
||||
if (createBtn) window.location.href = createBtn.href;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize tooltips
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize popovers
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
|
||||
popoverTriggerList.map(function (popoverTriggerEl) {
|
||||
return new bootstrap.Popover(popoverTriggerEl);
|
||||
});
|
||||
});
|
||||
|
||||
// Chart.js default configuration
|
||||
if (typeof Chart !== 'undefined') {
|
||||
Chart.defaults.font.family = "'Inter', sans-serif";
|
||||
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--text-secondary');
|
||||
}
|
||||
|
||||
// Handle dark mode chart updates
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const darkModeObserver = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.attributeName === 'class') {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
updateChartsForTheme(isDark);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
darkModeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
});
|
||||
|
||||
function updateChartsForTheme(isDark) {
|
||||
if (typeof Chart !== 'undefined' && Chart.instances.length > 0) {
|
||||
const textColor = isDark ? '#9ca3af' : '#6b7280';
|
||||
Chart.defaults.color = textColor;
|
||||
|
||||
// Update all chart instances
|
||||
Chart.instances.forEach(chart => {
|
||||
chart.options.scales.x.ticks.color = textColor;
|
||||
chart.options.scales.y.ticks.color = textColor;
|
||||
chart.options.scales.x.grid.color = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
chart.options.scales.y.grid.color = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
chart.update();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge updater
|
||||
function updateStatusBadge(element, status) {
|
||||
element.className = 'badge';
|
||||
if (status >= 200 && status < 400) {
|
||||
element.classList.add('bg-success');
|
||||
element.innerHTML = '<i class="bi bi-check-circle"></i> Online';
|
||||
} else if (status) {
|
||||
element.classList.add('bg-danger');
|
||||
element.innerHTML = '<i class="bi bi-x-circle"></i> Down';
|
||||
} else {
|
||||
element.classList.add('bg-secondary');
|
||||
element.textContent = 'Not Checked';
|
||||
}
|
||||
}
|
||||
|
||||
// Export data as CSV
|
||||
function exportToCSV(data, filename = 'export.csv') {
|
||||
const csv = data.map(row => row.join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Print functionality
|
||||
function printReport() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
// Console welcome message
|
||||
console.log(
|
||||
'%cSite Monitor Dashboard',
|
||||
'font-size: 20px; font-weight: bold; color: #4f46e5;'
|
||||
);
|
||||
console.log(
|
||||
'%cBuilt with Laravel + Bootstrap + Alpine.js',
|
||||
'font-size: 12px; color: #6b7280;'
|
||||
);
|
||||
Vendored
+30
@@ -1,4 +1,34 @@
|
||||
import 'bootstrap';
|
||||
|
||||
/**
|
||||
* We'll load the axios HTTP library which allows us to easily issue requests
|
||||
* to our Laravel back-end. This library automatically handles sending the
|
||||
* CSRF token as a header based on the value of the "XSRF" token cookie.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
/**
|
||||
* Echo exposes an expressive API for subscribing to channels and listening
|
||||
* for events that are broadcast by Laravel. Echo and event broadcasting
|
||||
* allows your team to easily build robust real-time web applications.
|
||||
*/
|
||||
|
||||
// import Echo from 'laravel-echo';
|
||||
|
||||
// import Pusher from 'pusher-js';
|
||||
// window.Pusher = Pusher;
|
||||
|
||||
// window.Echo = new Echo({
|
||||
// broadcaster: 'pusher',
|
||||
// key: import.meta.env.VITE_PUSHER_APP_KEY,
|
||||
// cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1',
|
||||
// wsHost: import.meta.env.VITE_PUSHER_HOST ?? `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`,
|
||||
// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80,
|
||||
// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443,
|
||||
// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
|
||||
// enabledTransports: ['ws', 'wss'],
|
||||
// });
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Body
|
||||
$body-bg: #f8fafc;
|
||||
|
||||
// Typography
|
||||
$font-family-sans-serif: 'Nunito', sans-serif;
|
||||
$font-size-base: 0.9rem;
|
||||
$line-height-base: 1.6;
|
||||
@@ -0,0 +1,8 @@
|
||||
// Fonts
|
||||
@import url('https://fonts.bunny.net/css?family=Nunito');
|
||||
|
||||
// Variables
|
||||
@import 'variables';
|
||||
|
||||
// Bootstrap
|
||||
@import 'bootstrap/scss/bootstrap';
|
||||
@@ -0,0 +1,115 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Alerts')
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1><i class="bi bi-bell"></i> Alerts</h1>
|
||||
<p class="text-muted mb-0">Notification history for all your sites</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<ul class="nav nav-tabs mb-4" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="all-tab" data-bs-toggle="tab" data-bs-target="#all" type="button" role="tab">
|
||||
All ({{ $notifications->total() }})
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="unread-tab" data-bs-toggle="tab" data-bs-target="#unread" type="button" role="tab">
|
||||
Unread ({{ $unreadCount }})
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="read-tab" data-bs-toggle="tab" data-bs-target="#read" type="button" role="tab">
|
||||
Read ({{ $readCount }})
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Notifications List -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@if($notifications->isEmpty())
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle"></i> No alerts yet. You'll receive notifications when issues are detected with your sites.
|
||||
</div>
|
||||
@else
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach($notifications as $notification)
|
||||
<div class="list-group-item {{ !$notification->is_read ? 'bg-light' : '' }} border-start border-{{ $notification->type === 'uptime' ? 'danger' : ($notification->type === 'ssl' ? 'warning' : 'info') }} border-3">
|
||||
<div class="d-flex w-100 justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
@if($notification->type === 'uptime')
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
|
||||
<span class="badge bg-danger me-2">Uptime Alert</span>
|
||||
@elseif($notification->type === 'ssl')
|
||||
<i class="bi bi-shield-exclamation text-warning me-2"></i>
|
||||
<span class="badge bg-warning me-2">SSL Alert</span>
|
||||
@else
|
||||
<i class="bi bi-link-45deg text-info me-2"></i>
|
||||
<span class="badge bg-info me-2">Broken Links</span>
|
||||
@endif
|
||||
@if(!$notification->is_read)
|
||||
<span class="badge bg-primary">New</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mb-1">{{ $notification->message }}</p>
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-clock"></i> {{ $notification->created_at->diffForHumans() }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
@if(!$notification->is_read)
|
||||
<form action="{{ route('alerts.markAsRead', $notification) }}" method="POST" class="d-inline">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary" title="Mark as read">
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
<form action="{{ route('alerts.destroy', $notification) }}" method="POST" class="d-inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete"
|
||||
onclick="return confirm('Are you sure you want to delete this alert?')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
{{ $notifications->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if($notifications->isNotEmpty())
|
||||
<div class="card-footer">
|
||||
<div class="d-flex gap-2">
|
||||
<form action="{{ route('alerts.markAllAsRead') }}" method="POST">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-check-all"></i> Mark All as Read
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ route('alerts.deleteRead') }}" method="POST" onsubmit="return confirm('Are you sure you want to delete all read alerts?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i> Delete All Read
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,73 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">{{ __('Login') }}</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('login') }}">
|
||||
@csrf
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="email" class="col-md-4 col-form-label text-md-end">{{ __('Email Address') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') }}" required autocomplete="email" autofocus>
|
||||
|
||||
@error('email')
|
||||
<span class="invalid-feedback" role="alert">
|
||||
<strong>{{ $message }}</strong>
|
||||
</span>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="password" class="col-md-4 col-form-label text-md-end">{{ __('Password') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="current-password">
|
||||
|
||||
@error('password')
|
||||
<span class="invalid-feedback" role="alert">
|
||||
<strong>{{ $message }}</strong>
|
||||
</span>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6 offset-md-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="remember" id="remember" {{ old('remember') ? 'checked' : '' }}>
|
||||
|
||||
<label class="form-check-label" for="remember">
|
||||
{{ __('Remember Me') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-0">
|
||||
<div class="col-md-8 offset-md-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ __('Login') }}
|
||||
</button>
|
||||
|
||||
@if (Route::has('password.request'))
|
||||
<a class="btn btn-link" href="{{ route('password.request') }}">
|
||||
{{ __('Forgot Your Password?') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,49 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">{{ __('Confirm Password') }}</div>
|
||||
|
||||
<div class="card-body">
|
||||
{{ __('Please confirm your password before continuing.') }}
|
||||
|
||||
<form method="POST" action="{{ route('password.confirm') }}">
|
||||
@csrf
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="password" class="col-md-4 col-form-label text-md-end">{{ __('Password') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="current-password">
|
||||
|
||||
@error('password')
|
||||
<span class="invalid-feedback" role="alert">
|
||||
<strong>{{ $message }}</strong>
|
||||
</span>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-0">
|
||||
<div class="col-md-8 offset-md-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ __('Confirm Password') }}
|
||||
</button>
|
||||
|
||||
@if (Route::has('password.request'))
|
||||
<a class="btn btn-link" href="{{ route('password.request') }}">
|
||||
{{ __('Forgot Your Password?') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,47 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">{{ __('Reset Password') }}</div>
|
||||
|
||||
<div class="card-body">
|
||||
@if (session('status'))
|
||||
<div class="alert alert-success" role="alert">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('password.email') }}">
|
||||
@csrf
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="email" class="col-md-4 col-form-label text-md-end">{{ __('Email Address') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') }}" required autocomplete="email" autofocus>
|
||||
|
||||
@error('email')
|
||||
<span class="invalid-feedback" role="alert">
|
||||
<strong>{{ $message }}</strong>
|
||||
</span>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-0">
|
||||
<div class="col-md-6 offset-md-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ __('Send Password Reset Link') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,65 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">{{ __('Reset Password') }}</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('password.update') }}">
|
||||
@csrf
|
||||
|
||||
<input type="hidden" name="token" value="{{ $token }}">
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="email" class="col-md-4 col-form-label text-md-end">{{ __('Email Address') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ $email ?? old('email') }}" required autocomplete="email" autofocus>
|
||||
|
||||
@error('email')
|
||||
<span class="invalid-feedback" role="alert">
|
||||
<strong>{{ $message }}</strong>
|
||||
</span>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="password" class="col-md-4 col-form-label text-md-end">{{ __('Password') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="new-password">
|
||||
|
||||
@error('password')
|
||||
<span class="invalid-feedback" role="alert">
|
||||
<strong>{{ $message }}</strong>
|
||||
</span>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="password-confirm" class="col-md-4 col-form-label text-md-end">{{ __('Confirm Password') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="password-confirm" type="password" class="form-control" name="password_confirmation" required autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-0">
|
||||
<div class="col-md-6 offset-md-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ __('Reset Password') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,77 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">{{ __('Register') }}</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('register') }}">
|
||||
@csrf
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="name" class="col-md-4 col-form-label text-md-end">{{ __('Name') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="name" type="text" class="form-control @error('name') is-invalid @enderror" name="name" value="{{ old('name') }}" required autocomplete="name" autofocus>
|
||||
|
||||
@error('name')
|
||||
<span class="invalid-feedback" role="alert">
|
||||
<strong>{{ $message }}</strong>
|
||||
</span>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="email" class="col-md-4 col-form-label text-md-end">{{ __('Email Address') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') }}" required autocomplete="email">
|
||||
|
||||
@error('email')
|
||||
<span class="invalid-feedback" role="alert">
|
||||
<strong>{{ $message }}</strong>
|
||||
</span>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="password" class="col-md-4 col-form-label text-md-end">{{ __('Password') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="password" type="password" class="form-control @error('password') is-invalid @enderror" name="password" required autocomplete="new-password">
|
||||
|
||||
@error('password')
|
||||
<span class="invalid-feedback" role="alert">
|
||||
<strong>{{ $message }}</strong>
|
||||
</span>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="password-confirm" class="col-md-4 col-form-label text-md-end">{{ __('Confirm Password') }}</label>
|
||||
|
||||
<div class="col-md-6">
|
||||
<input id="password-confirm" type="password" class="form-control" name="password_confirmation" required autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-0">
|
||||
<div class="col-md-6 offset-md-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ __('Register') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,28 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">{{ __('Verify Your Email Address') }}</div>
|
||||
|
||||
<div class="card-body">
|
||||
@if (session('resent'))
|
||||
<div class="alert alert-success" role="alert">
|
||||
{{ __('A fresh verification link has been sent to your email address.') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{ __('Before proceeding, please check your email for a verification link.') }}
|
||||
{{ __('If you did not receive the email') }},
|
||||
<form class="d-inline" method="POST" action="{{ route('verification.resend') }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-link p-0 m-0 align-baseline">{{ __('click here to request another') }}</button>.
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,20 @@
|
||||
@props(['type' => 'info', 'dismissible' => false])
|
||||
|
||||
@php
|
||||
$classes = [
|
||||
'success' => 'alert-success',
|
||||
'danger' => 'alert-danger',
|
||||
'warning' => 'alert-warning',
|
||||
'info' => 'alert-info',
|
||||
'primary' => 'alert-primary',
|
||||
'secondary' => 'alert-secondary',
|
||||
];
|
||||
$alertClass = $classes[$type] ?? $classes['info'];
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->merge(['class' => 'alert ' . $alertClass . ($dismissible ? ' alert-dismissible fade show' : '')]) }} role="alert">
|
||||
{{ $slot }}
|
||||
@if($dismissible)
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
@props(['color' => 'primary', 'pill' => false])
|
||||
|
||||
<span {{ $attributes->merge(['class' => 'badge bg-' . $color . ($pill ? ' rounded-pill' : '')]) }}>
|
||||
{{ $slot }}
|
||||
</span>
|
||||
@@ -0,0 +1,19 @@
|
||||
@props(['header' => null, 'footer' => null])
|
||||
|
||||
<div {{ $attributes->merge(['class' => 'card']) }}>
|
||||
@if($header)
|
||||
<div class="card-header">
|
||||
{{ $header }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="card-body">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@if($footer)
|
||||
<div class="card-footer">
|
||||
{{ $footer }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
@props(['id', 'title', 'size' => 'md'])
|
||||
|
||||
<div class="modal fade" id="{{ $id }}" tabindex="-1" aria-labelledby="{{ $id }}Label" aria-hidden="true">
|
||||
<div class="modal-dialog modal-{{ $size }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="{{ $id }}Label">{{ $title }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@isset($footer)
|
||||
<div class="modal-footer">
|
||||
{{ $footer }}
|
||||
</div>
|
||||
@endisset
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
@props(['title', 'value', 'icon', 'color' => 'primary'])
|
||||
|
||||
<div {{ $attributes->merge(['class' => 'card']) }}>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-1">{{ $title }}</h6>
|
||||
<h2 class="mb-0 text-{{ $color }}">{{ $value }}</h2>
|
||||
</div>
|
||||
<div class="text-{{ $color }}" style="font-size: 2rem;">
|
||||
<i class="bi bi-{{ $icon }}"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,508 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Dashboard')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.stat-card {
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--card-gradient-start), var(--card-gradient-end));
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-card.primary {
|
||||
--card-gradient-start: #667eea;
|
||||
--card-gradient-end: #764ba2;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05), rgba(118, 75, 162, 0.05));
|
||||
}
|
||||
|
||||
.stat-card.success {
|
||||
--card-gradient-start: #10b981;
|
||||
--card-gradient-end: #059669;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.05), rgba(5, 150, 105, 0.05));
|
||||
}
|
||||
|
||||
.stat-card.danger {
|
||||
--card-gradient-start: #ef4444;
|
||||
--card-gradient-end: #dc2626;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.05), rgba(220, 38, 38, 0.05));
|
||||
}
|
||||
|
||||
.stat-card.info {
|
||||
--card-gradient-start: #3b82f6;
|
||||
--card-gradient-end: #2563eb;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05), rgba(37, 99, 235, 0.05));
|
||||
}
|
||||
|
||||
.dark .stat-card.primary {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.1));
|
||||
}
|
||||
|
||||
.dark .stat-card.success {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(5, 150, 105, 0.1));
|
||||
}
|
||||
|
||||
.dark .stat-card.danger {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(220, 38, 38, 0.1));
|
||||
}
|
||||
|
||||
.dark .stat-card.info {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(37, 99, 235, 0.1));
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-icon.primary {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-icon.success {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-icon.danger {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-icon.info {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.chart-card .card-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.chart-card .card-body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.table-modern {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.table-modern thead th {
|
||||
background: transparent;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table-modern tbody tr {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-modern tbody tr:hover {
|
||||
background: var(--bg-secondary);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.table-modern tbody td {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.site-url {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-modern {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 4rem;
|
||||
opacity: 0.3;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
<p class="page-subtitle">Welcome back! Here's an overview of all your monitored sites.</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="stat-card primary">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<div class="stat-label">Total Sites</div>
|
||||
<div class="stat-value">{{ $totalSites }}</div>
|
||||
</div>
|
||||
<div class="stat-icon primary">
|
||||
<i class="bi bi-globe"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="stat-card success">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<div class="stat-label">Sites Up</div>
|
||||
<div class="stat-value text-success">{{ $sitesUp }}</div>
|
||||
</div>
|
||||
<div class="stat-icon success">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="stat-card danger">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<div class="stat-label">Sites Down</div>
|
||||
<div class="stat-value text-danger">{{ $sitesDown }}</div>
|
||||
</div>
|
||||
<div class="stat-icon danger">
|
||||
<i class="bi bi-exclamation-circle-fill"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="stat-card info">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<div class="stat-label">Avg Uptime</div>
|
||||
<div class="stat-value text-info">{{ number_format($avgUptime, 1) }}%</div>
|
||||
</div>
|
||||
<div class="stat-icon info">
|
||||
<i class="bi bi-graph-up-arrow"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7-Day Uptime Chart -->
|
||||
<div class="card chart-card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0 d-flex align-items-center">
|
||||
<i class="bi bi-graph-up me-2"></i>
|
||||
7-Day Uptime Overview
|
||||
</h5>
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-clock"></i> Last 7 days
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if($sites->isNotEmpty())
|
||||
<canvas id="weeklyUptimeChart" height="80"></canvas>
|
||||
@else
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<i class="bi bi-graph-up"></i>
|
||||
</div>
|
||||
<h5>No Data Available</h5>
|
||||
<p class="text-muted">Add your first site to start monitoring and viewing analytics.</p>
|
||||
<a href="{{ route('sites.create') }}" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-plus-circle"></i> Add Your First Site
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sites Table -->
|
||||
<div class="card chart-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0 d-flex align-items-center">
|
||||
<i class="bi bi-globe me-2"></i>
|
||||
Your Sites
|
||||
</h5>
|
||||
</div>
|
||||
<a href="{{ route('sites.create') }}" class="btn btn-primary btn-sm btn-action">
|
||||
<i class="bi bi-plus-circle"></i> Add Site
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if($sites->isNotEmpty())
|
||||
<div class="table-responsive">
|
||||
<table class="table table-modern mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Status</th>
|
||||
<th>Uptime</th>
|
||||
<th>Response</th>
|
||||
<th>SSL</th>
|
||||
<th>Links</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($sites as $site)
|
||||
@php
|
||||
$latestCheck = $site->uptimeChecks->first();
|
||||
$latestSsl = $site->sslCertificates->first();
|
||||
$daysUntilExpiry = $latestSsl ? now()->diffInDays($latestSsl->expires_at, false) : null;
|
||||
@endphp
|
||||
<tr>
|
||||
<td>
|
||||
<div class="site-name">{{ $site->name }}</div>
|
||||
<div class="site-url">
|
||||
<i class="bi bi-link-45deg"></i> {{ Str::limit($site->url, 40) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if(!$latestCheck)
|
||||
<span class="badge badge-modern bg-secondary">
|
||||
<i class="bi bi-question-circle"></i> Not Checked
|
||||
</span>
|
||||
@elseif($latestCheck->status_code >= 200 && $latestCheck->status_code < 400)
|
||||
<span class="badge badge-modern bg-success">
|
||||
<i class="bi bi-check-circle-fill"></i> Online
|
||||
</span>
|
||||
@else
|
||||
<span class="badge badge-modern bg-danger">
|
||||
<i class="bi bi-x-circle-fill"></i> Down
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@php
|
||||
$uptime = $site->uptime_percentage ?? 0;
|
||||
@endphp
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress" style="width: 60px; height: 6px;">
|
||||
<div class="progress-bar bg-{{ $uptime >= 99 ? 'success' : ($uptime >= 95 ? 'warning' : 'danger') }}"
|
||||
role="progressbar"
|
||||
style="width: {{ $uptime }}%"></div>
|
||||
</div>
|
||||
<span class="small fw-semibold">{{ number_format($uptime, 1) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($site->avg_response_time)
|
||||
<span class="fw-semibold">{{ round($site->avg_response_time) }}</span>
|
||||
<span class="text-muted small">ms</span>
|
||||
@else
|
||||
<span class="text-muted">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if($latestSsl)
|
||||
@if($daysUntilExpiry < 0)
|
||||
<span class="badge badge-modern bg-danger">
|
||||
<i class="bi bi-shield-x"></i> Expired
|
||||
</span>
|
||||
@elseif($daysUntilExpiry < 30)
|
||||
<span class="badge badge-modern bg-warning text-dark">
|
||||
<i class="bi bi-clock-history"></i> {{ $daysUntilExpiry }}d
|
||||
</span>
|
||||
@else
|
||||
<span class="badge badge-modern bg-success">
|
||||
<i class="bi bi-shield-check"></i> Valid
|
||||
</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="text-muted">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if($site->broken_links_count > 0)
|
||||
<span class="badge badge-modern bg-danger">
|
||||
<i class="bi bi-link-45deg"></i> {{ $site->broken_links_count }}
|
||||
</span>
|
||||
@else
|
||||
<span class="badge badge-modern bg-success">
|
||||
<i class="bi bi-check-circle"></i> 0
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ route('dashboard.show', $site) }}" class="btn btn-sm btn-outline-primary btn-action">
|
||||
<i class="bi bi-arrow-right-circle"></i> Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<i class="bi bi-globe"></i>
|
||||
</div>
|
||||
<h5>No Sites Yet</h5>
|
||||
<p class="text-muted">Start monitoring your websites by adding your first site.</p>
|
||||
<a href="{{ route('sites.create') }}" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-plus-circle"></i> Add Your First Site
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@if($sites->isNotEmpty() && isset($chartData))
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ctx = document.getElementById('weeklyUptimeChart').getContext('2d');
|
||||
|
||||
const chartData = @json($chartData);
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: chartData.labels,
|
||||
datasets: chartData.datasets.map((dataset, index) => ({
|
||||
label: dataset.label,
|
||||
data: dataset.data,
|
||||
borderColor: dataset.color,
|
||||
backgroundColor: dataset.color + '33',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1
|
||||
}))
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Uptime %'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Date'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
@endpush
|
||||
@@ -0,0 +1,230 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $site->name . ' Dashboard')
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1><i class="bi bi-bar-chart"></i> {{ $site->name }}</h1>
|
||||
<p class="text-muted mb-0">
|
||||
<i class="bi bi-link-45deg"></i> <a href="{{ $site->url }}" target="_blank">{{ $site->url }}</a>
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('sites.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Sites
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card text-white bg-primary">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Uptime</h6>
|
||||
<h2 class="mb-0">{{ $uptimePercentage }}%</h2>
|
||||
<small>Last 30 days</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card text-white bg-success">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Avg Response Time</h6>
|
||||
<h2 class="mb-0">{{ $avgResponseTime ? round($avgResponseTime) : 'N/A' }}</h2>
|
||||
<small>{{ $avgResponseTime ? 'milliseconds' : 'No data' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card text-white {{ $brokenLinks->total() > 0 ? 'bg-danger' : 'bg-success' }}">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Broken Links</h6>
|
||||
<h2 class="mb-0">{{ $brokenLinks->total() }}</h2>
|
||||
<small>{{ $brokenLinks->total() == 1 ? 'link' : 'links' }} detected</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uptime Chart -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Response Time History (Last 30 Days)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if($uptimeChecks->isNotEmpty())
|
||||
<canvas id="uptimeChart" height="80"></canvas>
|
||||
@else
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> No uptime data available yet. The site will be checked automatically.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Certificate -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-shield-lock"></i> SSL Certificate</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if($latestSslCert)
|
||||
@php
|
||||
$daysUntilExpiry = now()->diffInDays($latestSslCert->expires_at, false);
|
||||
$isExpired = $daysUntilExpiry < 0;
|
||||
$isExpiringSoon = $daysUntilExpiry < 30 && !$isExpired;
|
||||
@endphp
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<strong>Issuer:</strong><br>
|
||||
{{ $latestSslCert->issuer }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>Expires:</strong><br>
|
||||
{{ $latestSslCert->expires_at->format('M d, Y') }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>Days Remaining:</strong><br>
|
||||
<span class="badge bg-{{ $isExpired ? 'danger' : ($isExpiringSoon ? 'warning' : 'success') }}">
|
||||
{{ $isExpired ? 'EXPIRED' : $daysUntilExpiry . ' days' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>Last Checked:</strong><br>
|
||||
{{ $latestSslCert->checked_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle"></i> No SSL certificate data available yet.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Broken Links -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-link-45deg"></i> Broken Links</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if($brokenLinks->isNotEmpty())
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th>Status Code</th>
|
||||
<th>Last Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($brokenLinks as $link)
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ $link->url }}" target="_blank" class="text-decoration-none">
|
||||
{{ $link->url }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-danger">{{ $link->status_code ?? 'Failed' }}</span>
|
||||
</td>
|
||||
<td>{{ $link->last_checked_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
{{ $brokenLinks->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="alert alert-success mb-0">
|
||||
<i class="bi bi-check-circle"></i> No broken links detected! Your site is looking good.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@if($uptimeChecks->isNotEmpty())
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ctx = document.getElementById('uptimeChart').getContext('2d');
|
||||
|
||||
const labels = @json($chartLabels);
|
||||
const responseData = @json($chartData);
|
||||
const statusCodes = @json($chartStatusCodes);
|
||||
|
||||
// Create background colors based on status codes
|
||||
const backgroundColors = statusCodes.map(code => {
|
||||
if (!code) return 'rgba(220, 53, 69, 0.5)'; // Red for null/failed
|
||||
if (code >= 200 && code < 400) return 'rgba(25, 135, 84, 0.5)'; // Green for success
|
||||
return 'rgba(220, 53, 69, 0.5)'; // Red for errors
|
||||
});
|
||||
|
||||
const borderColors = statusCodes.map(code => {
|
||||
if (!code) return 'rgb(220, 53, 69)';
|
||||
if (code >= 200 && code < 400) return 'rgb(25, 135, 84)';
|
||||
return 'rgb(220, 53, 69)';
|
||||
});
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Response Time (ms)',
|
||||
data: responseData,
|
||||
borderColor: 'rgb(13, 110, 253)',
|
||||
backgroundColor: backgroundColors,
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointBackgroundColor: backgroundColors,
|
||||
pointBorderColor: borderColors,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
afterLabel: function(context) {
|
||||
const statusCode = statusCodes[context.dataIndex];
|
||||
return statusCode ? 'HTTP ' + statusCode : 'Connection Failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Response Time (ms)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Check Time'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
@endpush
|
||||
@@ -0,0 +1,23 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">{{ __('Dashboard') }}</div>
|
||||
|
||||
<div class="card-body">
|
||||
@if (session('status'))
|
||||
<div class="alert alert-success" role="alert">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{ __('You are logged in!') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,267 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" x-data="{ darkMode: localStorage.getItem('darkMode') === 'true' }" :class="{ 'dark': darkMode }">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>@yield('title', 'Dashboard') - {{ config('app.name') }}</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--bg-sidebar: #1f2937;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--border-color: #e5e7eb;
|
||||
--indigo-600: #4f46e5;
|
||||
--indigo-700: #4338ca;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg-primary: #111827;
|
||||
--bg-secondary: #1f2937;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #9ca3af;
|
||||
--border-color: #374151;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 260px;
|
||||
background-color: var(--bg-sidebar);
|
||||
padding: 1.5rem 0;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.sidebar-nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: #9ca3af;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-nav-link:hover,
|
||||
.sidebar-nav-link.active {
|
||||
background-color: rgba(79, 70, 229, 0.1);
|
||||
color: var(--indigo-600);
|
||||
}
|
||||
|
||||
.sidebar-nav-link i {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 260px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--indigo-600);
|
||||
border-color: var(--indigo-600);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--indigo-700);
|
||||
border-color: var(--indigo-700);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body>
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<h4 class="text-white mb-0">
|
||||
<i class="bi bi-shield-check"></i>
|
||||
Site Monitor
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-nav">
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="{{ route('dashboard') }}" class="sidebar-nav-link {{ request()->routeIs('dashboard') ? 'active' : '' }}">
|
||||
<i class="bi bi-speedometer2"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="{{ route('sites.index') }}" class="sidebar-nav-link {{ request()->routeIs('sites.*') ? 'active' : '' }}">
|
||||
<i class="bi bi-globe"></i>
|
||||
Sites
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="{{ route('alerts.index') }}" class="sidebar-nav-link {{ request()->routeIs('alerts.*') ? 'active' : '' }}">
|
||||
<i class="bi bi-bell"></i>
|
||||
Alerts
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="{{ route('settings') }}" class="sidebar-nav-link {{ request()->routeIs('settings') ? 'active' : '' }}">
|
||||
<i class="bi bi-gear"></i>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<!-- Topbar -->
|
||||
<div class="topbar">
|
||||
<button class="btn btn-outline-secondary d-md-none" onclick="document.getElementById('sidebar').classList.toggle('show')">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
|
||||
<div></div>
|
||||
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<button class="btn btn-outline-secondary" @click="darkMode = !darkMode; localStorage.setItem('darkMode', darkMode)">
|
||||
<i class="bi" :class="darkMode ? 'bi-sun' : 'bi-moon'"></i>
|
||||
</button>
|
||||
|
||||
@auth
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
{{ auth()->user()->name }}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
@if (session('success'))
|
||||
<x-alert type="success" dismissible>
|
||||
{{ session('success') }}
|
||||
</x-alert>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<x-alert type="danger" dismissible>
|
||||
{{ session('error') }}
|
||||
</x-alert>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<x-alert type="danger" dismissible>
|
||||
<strong>Validation Error:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</x-alert>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
<script>
|
||||
function confirmDelete(message) {
|
||||
return confirm(message || 'Are you sure you want to delete this?');
|
||||
}
|
||||
</script>
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,144 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Settings')
|
||||
|
||||
@section('content')
|
||||
<div class="mb-4">
|
||||
<h1><i class="bi bi-gear"></i> Settings</h1>
|
||||
<p class="text-muted">Manage your account and notification preferences</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Notification Preferences -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-bell"></i> Notification Preferences</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ route('settings.notifications') }}" method="POST">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
|
||||
<p class="text-muted mb-4">Choose how you want to be notified about issues with your sites.</p>
|
||||
|
||||
<!-- Uptime Alerts -->
|
||||
<div class="mb-4">
|
||||
<h6 class="mb-3">Uptime Alerts</h6>
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="uptime_email" name="uptime[]" value="mail"
|
||||
{{ in_array('mail', $preferences['uptime'] ?? []) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="uptime_email">
|
||||
<i class="bi bi-envelope"></i> Email Notifications
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="uptime_slack" name="uptime[]" value="slack"
|
||||
{{ in_array('slack', $preferences['uptime'] ?? []) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="uptime_slack">
|
||||
<i class="bi bi-slack"></i> Slack Notifications
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Expiry Alerts -->
|
||||
<div class="mb-4">
|
||||
<h6 class="mb-3">SSL Certificate Alerts</h6>
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="ssl_email" name="ssl[]" value="mail"
|
||||
{{ in_array('mail', $preferences['ssl'] ?? []) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="ssl_email">
|
||||
<i class="bi bi-envelope"></i> Email Notifications
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="ssl_slack" name="ssl[]" value="slack"
|
||||
{{ in_array('slack', $preferences['ssl'] ?? []) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="ssl_slack">
|
||||
<i class="bi bi-slack"></i> Slack Notifications
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Broken Links Alerts -->
|
||||
<div class="mb-4">
|
||||
<h6 class="mb-3">Broken Links Alerts</h6>
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="broken_links_email" name="broken_links[]" value="mail"
|
||||
{{ in_array('mail', $preferences['broken_links'] ?? []) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="broken_links_email">
|
||||
<i class="bi bi-envelope"></i> Email Notifications
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="broken_links_slack" name="broken_links[]" value="slack"
|
||||
{{ in_array('slack', $preferences['broken_links'] ?? []) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="broken_links_slack">
|
||||
<i class="bi bi-slack"></i> Slack Notifications
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Save Preferences
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Settings -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-person"></i> Profile</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Name</label>
|
||||
<p class="mb-0">{{ auth()->user()->name }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Email</label>
|
||||
<p class="mb-0">{{ auth()->user()->email }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Member Since</label>
|
||||
<p class="mb-0">{{ auth()->user()->created_at->format('M d, Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slack Integration -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-slack"></i> Slack Integration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ route('settings.slack') }}" method="POST">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="slack_webhook" class="form-label">Slack Webhook URL</label>
|
||||
<input type="url" class="form-control @error('slack_webhook') is-invalid @enderror"
|
||||
id="slack_webhook" name="slack_webhook"
|
||||
value="{{ old('slack_webhook', $slackWebhook ?? '') }}"
|
||||
placeholder="https://hooks.slack.com/...">
|
||||
@error('slack_webhook')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
<div class="form-text">
|
||||
<a href="https://api.slack.com/messaging/webhooks" target="_blank">Get your webhook URL</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Save Webhook
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,61 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Add New Site')
|
||||
|
||||
@section('content')
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0"><i class="bi bi-plus-circle"></i> Add New Site</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ route('sites.store') }}" method="POST">
|
||||
@csrf
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Site Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control @error('name') is-invalid @enderror"
|
||||
id="name" name="name" value="{{ old('name') }}" required>
|
||||
@error('name')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
<div class="form-text">A friendly name to identify your site</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="url" class="form-label">Site URL <span class="text-danger">*</span></label>
|
||||
<input type="url" class="form-control @error('url') is-invalid @enderror"
|
||||
id="url" name="url" value="{{ old('url') }}" required
|
||||
placeholder="https://example.com">
|
||||
@error('url')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
<div class="form-text">The full URL of your site (must start with http:// or https://)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="monitoring_enabled"
|
||||
name="monitoring_enabled" {{ old('monitoring_enabled', true) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="monitoring_enabled">
|
||||
Enable Monitoring
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">Start monitoring this site immediately</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Add Site
|
||||
</button>
|
||||
<a href="{{ route('sites.index') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,61 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Edit Site')
|
||||
|
||||
@section('content')
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0"><i class="bi bi-pencil"></i> Edit Site</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ route('sites.update', $site) }}" method="POST">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Site Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control @error('name') is-invalid @enderror"
|
||||
id="name" name="name" value="{{ old('name', $site->name) }}" required>
|
||||
@error('name')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
<div class="form-text">A friendly name to identify your site</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="url" class="form-label">Site URL <span class="text-danger">*</span></label>
|
||||
<input type="url" class="form-control @error('url') is-invalid @enderror"
|
||||
id="url" name="url" value="{{ old('url', $site->url) }}" required>
|
||||
@error('url')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
<div class="form-text">The full URL of your site (must start with http:// or https://)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="monitoring_enabled"
|
||||
name="monitoring_enabled" {{ old('monitoring_enabled', $site->monitoring_enabled) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="monitoring_enabled">
|
||||
Enable Monitoring
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">Enable or disable monitoring for this site</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Update Site
|
||||
</button>
|
||||
<a href="{{ route('sites.index') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,93 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'My Sites')
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-list-ul"></i> My Sites</h1>
|
||||
<a href="{{ route('sites.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add New Site
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if($sites->isEmpty())
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> You haven't added any sites yet. Click "Add New Site" to get started!
|
||||
</div>
|
||||
@else
|
||||
<div class="row">
|
||||
@foreach($sites as $site)
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">{{ $site->name }}</h5>
|
||||
@if($site->latestUptimeCheck)
|
||||
@if($site->latestUptimeCheck->status_code >= 200 && $site->latestUptimeCheck->status_code < 400)
|
||||
<span class="badge bg-success">Online</span>
|
||||
@else
|
||||
<span class="badge bg-danger">Down</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="badge bg-secondary">Not Checked</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="card-text text-muted small mb-3">
|
||||
<i class="bi bi-link-45deg"></i> {{ $site->url }}
|
||||
</p>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<div class="border rounded p-2 text-center">
|
||||
<div class="small text-muted">Uptime Checks</div>
|
||||
<strong>{{ $site->uptime_checks_count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="border rounded p-2 text-center">
|
||||
<div class="small text-muted">Broken Links</div>
|
||||
<strong class="{{ $site->broken_links_count > 0 ? 'text-danger' : '' }}">
|
||||
{{ $site->broken_links_count }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($site->latestSslCertificate)
|
||||
@php
|
||||
$daysUntilExpiry = now()->diffInDays($site->latestSslCertificate->expires_at, false);
|
||||
@endphp
|
||||
<div class="alert alert-{{ $daysUntilExpiry < 30 ? 'warning' : 'success' }} alert-sm py-1 px-2 mb-3">
|
||||
<small>
|
||||
<i class="bi bi-shield-lock"></i> SSL expires in {{ $daysUntilExpiry }} days
|
||||
</small>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ route('dashboard.show', $site) }}" class="btn btn-sm btn-outline-primary flex-fill">
|
||||
<i class="bi bi-bar-chart"></i> Dashboard
|
||||
</a>
|
||||
<a href="{{ route('sites.edit', $site) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form action="{{ route('sites.destroy', $site) }}" method="POST" class="d-inline"
|
||||
onsubmit="return confirm('Are you sure you want to delete this site?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer small text-muted">
|
||||
<i class="bi bi-clock"></i> Added {{ $site->created_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
@@ -0,0 +1,494 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ config('app.name') }} - Website Monitoring Made Simple</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 100px 0 80px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-section p.lead {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.btn-hero {
|
||||
padding: 1rem 2.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-hero-primary {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-hero-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.btn-hero-secondary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.btn-hero-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 2.5rem;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
height: 100%;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.8rem;
|
||||
color: white;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: #6b7280;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background: #f9fafb;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
color: #667eea;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-section h2 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.navbar-landing {
|
||||
padding: 1.5rem 0;
|
||||
background: transparent;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.navbar-landing .navbar-brand {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navbar-landing .nav-link {
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.navbar-landing .btn-login {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-landing .btn-login:hover {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.pricing-badge {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-section h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-section p.lead {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.btn-hero-secondary {
|
||||
margin-left: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-landing">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<i class="bi bi-shield-check"></i> {{ config('app.name', 'Site Monitor') }}
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbarNav">
|
||||
<ul class="navbar-nav align-items-center">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#features">Features</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#pricing">Pricing</a>
|
||||
</li>
|
||||
@guest
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-login" href="{{ route('login') }}">Login</a>
|
||||
</li>
|
||||
@else
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-login" href="{{ route('dashboard') }}">Dashboard</a>
|
||||
</li>
|
||||
@endguest
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero-section">
|
||||
<div class="container position-relative">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-7">
|
||||
<h1>Monitor Your Websites With Confidence</h1>
|
||||
<p class="lead">Get instant alerts when your site goes down, SSL certificates expire, or broken links appear.</p>
|
||||
<div class="mt-4">
|
||||
@guest
|
||||
<a href="{{ route('register') }}" class="btn-hero btn-hero-primary">Get Started Free</a>
|
||||
<a href="#features" class="btn-hero btn-hero-secondary">Learn More</a>
|
||||
@else
|
||||
<a href="{{ route('dashboard') }}" class="btn-hero btn-hero-primary">Go to Dashboard</a>
|
||||
<a href="{{ route('sites.create') }}" class="btn-hero btn-hero-secondary">Add Your First Site</a>
|
||||
@endguest
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 d-none d-lg-block text-center">
|
||||
<i class="bi bi-graph-up-arrow" style="font-size: 15rem; opacity: 0.2;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section class="stats-section">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">99.9%</span>
|
||||
<div class="stat-label">Uptime Guaranteed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">5 min</span>
|
||||
<div class="stat-label">Check Intervals</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">24/7</span>
|
||||
<div class="stat-label">Monitoring</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-5" style="padding: 80px 0;">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="display-4 fw-bold mb-3">Everything You Need to Monitor Your Sites</h2>
|
||||
<p class="lead text-muted">Comprehensive monitoring tools to keep your websites running smoothly</p>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="bi bi-activity"></i>
|
||||
</div>
|
||||
<h3>Uptime Monitoring</h3>
|
||||
<p>Check your website's availability every 5 minutes. Get instant alerts via email or Slack when your site goes down.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
</div>
|
||||
<h3>SSL Certificate Checks</h3>
|
||||
<p>Never let your SSL certificates expire. Receive notifications 30 days before expiry to renew on time.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</div>
|
||||
<h3>Broken Link Detection</h3>
|
||||
<p>Automatically scan your website for broken links. Keep your SEO strong and user experience smooth.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="bi bi-graph-up"></i>
|
||||
</div>
|
||||
<h3>Performance Tracking</h3>
|
||||
<p>Track response times and view historical performance data with beautiful charts and analytics.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="bi bi-bell"></i>
|
||||
</div>
|
||||
<h3>Smart Notifications</h3>
|
||||
<p>Customize notification preferences for each check type. Get alerts via email or Slack integration.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<i class="bi bi-speedometer2"></i>
|
||||
</div>
|
||||
<h3>Beautiful Dashboard</h3>
|
||||
<p>View all your sites at a glance with an intuitive dashboard. Dark mode included for late-night monitoring.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pricing Section -->
|
||||
<section id="pricing" class="py-5 bg-light" style="padding: 80px 0;">
|
||||
<div class="container">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="display-4 fw-bold mb-3">Simple, Transparent Pricing</h2>
|
||||
<p class="lead text-muted">Start monitoring for free, upgrade as you grow</p>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 mb-4">
|
||||
<div class="feature-card text-center">
|
||||
<div class="mb-3">
|
||||
<span class="pricing-badge">FREE FOREVER</span>
|
||||
</div>
|
||||
<h3>Starter</h3>
|
||||
<div class="my-4">
|
||||
<span class="display-3 fw-bold">$0</span>
|
||||
<span class="text-muted">/month</span>
|
||||
</div>
|
||||
<ul class="list-unstyled text-start mb-4">
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> Up to 5 sites</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> 5-minute checks</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> Email notifications</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> SSL monitoring</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> Broken link detection</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> 30 days history</li>
|
||||
</ul>
|
||||
<a href="{{ route('register') }}" class="btn btn-lg btn-outline-primary w-100">Get Started</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5 mb-4">
|
||||
<div class="feature-card text-center" style="border: 2px solid #667eea;">
|
||||
<div class="mb-3">
|
||||
<span class="pricing-badge" style="background: #667eea;">COMING SOON</span>
|
||||
</div>
|
||||
<h3>Professional</h3>
|
||||
<div class="my-4">
|
||||
<span class="display-3 fw-bold">$19</span>
|
||||
<span class="text-muted">/month</span>
|
||||
</div>
|
||||
<ul class="list-unstyled text-start mb-4">
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> Unlimited sites</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> 1-minute checks</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> Email + Slack notifications</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> Advanced SSL monitoring</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> Deep link crawling</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> Unlimited history</li>
|
||||
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i> Priority support</li>
|
||||
</ul>
|
||||
<button class="btn btn-lg w-100" style="background: #667eea; color: white;" disabled>Coming Soon</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="cta-section">
|
||||
<div class="container">
|
||||
<h2>Ready to Monitor Your Websites?</h2>
|
||||
<p class="lead mb-4">Join thousands of developers who trust us to keep their sites online.</p>
|
||||
@guest
|
||||
<a href="{{ route('register') }}" class="btn-hero btn-hero-primary">Start Monitoring for Free</a>
|
||||
@else
|
||||
<a href="{{ route('sites.create') }}" class="btn-hero btn-hero-primary">Add Your First Site</a>
|
||||
@endguest
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-dark text-white py-5">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
<h5 class="mb-3"><i class="bi bi-shield-check"></i> {{ config('app.name', 'Site Monitor') }}</h5>
|
||||
<p class="text-muted">Professional website monitoring made simple. Keep your sites online 24/7.</p>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
<h6 class="mb-3">Product</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2"><a href="#features" class="text-muted text-decoration-none">Features</a></li>
|
||||
<li class="mb-2"><a href="#pricing" class="text-muted text-decoration-none">Pricing</a></li>
|
||||
@guest
|
||||
<li class="mb-2"><a href="{{ route('login') }}" class="text-muted text-decoration-none">Login</a></li>
|
||||
<li class="mb-2"><a href="{{ route('register') }}" class="text-muted text-decoration-none">Sign Up</a></li>
|
||||
@endguest
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-3">Built With</h6>
|
||||
<p class="text-muted">Laravel " MySQL " Bootstrap " Alpine.js " Chart.js</p>
|
||||
<p class="text-muted small mt-3">© {{ date('Y') }} {{ config('app.name') }}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Smooth scrolling for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,29 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
// Schedule uptime monitoring to run every 5 minutes
|
||||
Schedule::command('monitor:uptime')
|
||||
->everyFiveMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->appendOutputTo(storage_path('logs/uptime-monitoring.log'));
|
||||
|
||||
// Schedule SSL certificate monitoring to run daily
|
||||
Schedule::command('monitor:ssl')
|
||||
->daily()
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->appendOutputTo(storage_path('logs/ssl-monitoring.log'));
|
||||
|
||||
// Schedule broken link monitoring to run weekly
|
||||
Schedule::command('monitor:links')
|
||||
->weekly()
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->appendOutputTo(storage_path('logs/link-monitoring.log'));
|
||||
|
||||
+30
-1
@@ -1,7 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AlertController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\SettingsController;
|
||||
use App\Http\Controllers\SiteController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
return view('welcome-landing');
|
||||
})->name('home');
|
||||
|
||||
Auth::routes();
|
||||
|
||||
// Override the default /home route from Auth scaffolding
|
||||
Route::get('/home', function () {
|
||||
return redirect()->route('dashboard');
|
||||
});
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||
Route::resource('sites', SiteController::class);
|
||||
Route::get('dashboard/{site}', [DashboardController::class, 'show'])->name('dashboard.show');
|
||||
|
||||
// Alerts
|
||||
Route::get('/alerts', [AlertController::class, 'index'])->name('alerts.index');
|
||||
Route::patch('/alerts/{notification}/read', [AlertController::class, 'markAsRead'])->name('alerts.markAsRead');
|
||||
Route::patch('/alerts/read-all', [AlertController::class, 'markAllAsRead'])->name('alerts.markAllAsRead');
|
||||
Route::delete('/alerts/{notification}', [AlertController::class, 'destroy'])->name('alerts.destroy');
|
||||
Route::delete('/alerts/delete-read', [AlertController::class, 'deleteRead'])->name('alerts.deleteRead');
|
||||
|
||||
// Settings
|
||||
Route::get('/settings', [SettingsController::class, 'index'])->name('settings');
|
||||
Route::patch('/settings/notifications', [SettingsController::class, 'updateNotifications'])->name('settings.notifications');
|
||||
Route::patch('/settings/slack', [SettingsController::class, 'updateSlack'])->name('settings.slack');
|
||||
});
|
||||
|
||||
+4
-8
@@ -1,18 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
input: [
|
||||
'resources/sass/app.scss',
|
||||
'resources/js/app.js',
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user