lots of progress

This commit is contained in:
Doar Mihai
2026-01-17 13:46:20 +02:00
parent ab5d3cae98
commit 030a105992
77 changed files with 8507 additions and 20 deletions
@@ -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;
}
}
+66
View File
@@ -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');
}
}
+6 -2
View File
@@ -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'
));
}
}
+28
View File
@@ -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.');
}
}
+114
View File
@@ -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!');
}
}
+156
View File
@@ -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()}");
}
}
}
+128
View File
@@ -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()}");
}
}
}
+108
View File
@@ -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");
}
}
+27
View File
@@ -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);
}
}
+27
View File
@@ -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);
}
}
+41
View File
@@ -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);
}
}
+28
View File
@@ -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);
}
}
+27
View File
@@ -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);
}
}
+13
View File
@@ -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);
}
}
+85
View File
@@ -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,
];
}
}
+118
View File
@@ -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,
];
}
}
+72
View File
@@ -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,
];
}
}
+66
View File
@@ -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;
}
}
+66
View File
@@ -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;
}
}
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+29
View File
@@ -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'),
];
}
}
+27
View File
@@ -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'),
];
}
}
+29
View File
@@ -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');
});
}
};
+1
View File
@@ -20,6 +20,7 @@ class DatabaseSeeder extends Seeder
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
}
}
+50
View File
@@ -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}");
}
}
+2856
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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
View File
@@ -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"/>
+206
View File
@@ -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;
}
+232
View File
@@ -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;'
);
+30
View File
@@ -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'],
// });
+7
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
// Fonts
@import url('https://fonts.bunny.net/css?family=Nunito');
// Variables
@import 'variables';
// Bootstrap
@import 'bootstrap/scss/bootstrap';
+115
View File
@@ -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
+73
View File
@@ -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
+77
View File
@@ -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
+28
View File
@@ -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>
+19
View File
@@ -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>
+508
View File
@@ -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
+230
View File
@@ -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
+23
View File
@@ -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
+267
View File
@@ -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>
+144
View File
@@ -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
+61
View File
@@ -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
+61
View File
@@ -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
+93
View File
@@ -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
+494
View File
@@ -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">&copy; {{ 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
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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/**'],
},
},
});