some progress
This commit is contained in:
@@ -0,0 +1,236 @@
|
|||||||
|
# Domain Expiration Monitoring Setup
|
||||||
|
|
||||||
|
This feature allows you to monitor domain expiration dates and receive alerts when domains are expiring soon.
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### 1. Database Migration
|
||||||
|
- **Table:** `domains`
|
||||||
|
- **Columns:**
|
||||||
|
- `id` - Primary key
|
||||||
|
- `site_id` - Foreign key to sites table
|
||||||
|
- `domain` - Domain name (e.g., example.com)
|
||||||
|
- `expires_at` - Domain expiration date
|
||||||
|
- `last_checked_at` - Last WHOIS check timestamp
|
||||||
|
- `is_active` - Enable/disable monitoring
|
||||||
|
- `created_at`, `updated_at` - Timestamps
|
||||||
|
|
||||||
|
### 2. Models & Relationships
|
||||||
|
- **Domain Model** (`app/Models/Domain.php`)
|
||||||
|
- Belongs to Site
|
||||||
|
- Helper methods:
|
||||||
|
- `isExpiringSoon()` - Check if expiring within 30 days
|
||||||
|
- `isExpired()` - Check if already expired
|
||||||
|
- `daysUntilExpiration()` - Get days remaining
|
||||||
|
|
||||||
|
- **Site Model** updated with `domains()` relationship
|
||||||
|
|
||||||
|
### 3. Background Job
|
||||||
|
- **Job:** `CheckDomainExpiry` (`app/Jobs/CheckDomainExpiry.php`)
|
||||||
|
- Performs WHOIS lookup via API
|
||||||
|
- Updates domain expiration date
|
||||||
|
- Creates notifications for expiring domains (within 30 days)
|
||||||
|
- Logs errors for monitoring
|
||||||
|
|
||||||
|
### 4. Artisan Command
|
||||||
|
- **Command:** `monitor:domains`
|
||||||
|
- Dispatches `CheckDomainExpiry` job for all active domains
|
||||||
|
- Shows progress bar
|
||||||
|
- Run manually: `php artisan monitor:domains`
|
||||||
|
|
||||||
|
### 5. Scheduled Task
|
||||||
|
- **Schedule:** Daily at midnight (configurable in `routes/console.php`)
|
||||||
|
- Automatically runs `monitor:domains` command
|
||||||
|
- Logs output to `storage/logs/domain-monitoring.log`
|
||||||
|
|
||||||
|
### 6. Web Interface
|
||||||
|
|
||||||
|
#### Routes
|
||||||
|
- `GET /domains` - List all domains
|
||||||
|
- `GET /domains/create` - Add new domain form
|
||||||
|
- `POST /domains` - Store new domain
|
||||||
|
- `GET /domains/{domain}` - View domain details
|
||||||
|
- `GET /domains/{domain}/edit` - Edit domain form
|
||||||
|
- `PUT /domains/{domain}` - Update domain
|
||||||
|
- `DELETE /domains/{domain}` - Delete domain
|
||||||
|
- `POST /domains/{domain}/check` - Manually trigger WHOIS check
|
||||||
|
|
||||||
|
#### Views
|
||||||
|
- `resources/views/domains/index.blade.php` - Domain list with status badges
|
||||||
|
- `resources/views/domains/create.blade.php` - Add new domain
|
||||||
|
- `resources/views/domains/edit.blade.php` - Edit domain
|
||||||
|
- `resources/views/domains/show.blade.php` - Domain details and expiration info
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Configure WHOIS API
|
||||||
|
|
||||||
|
The system uses **WHOIS XML API** (https://www.whoisxmlapi.com/) by default. You can use alternatives like:
|
||||||
|
- WhoisFreaks (https://whoisfreaks.com/)
|
||||||
|
- Domainr (https://domainr.com/)
|
||||||
|
|
||||||
|
**Add to `.env` file:**
|
||||||
|
```env
|
||||||
|
WHOIS_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get a free API key:**
|
||||||
|
1. Visit https://www.whoisxmlapi.com/
|
||||||
|
2. Sign up for a free account
|
||||||
|
3. Get your API key from the dashboard
|
||||||
|
4. Free tier includes 500 requests/month
|
||||||
|
|
||||||
|
### 2. Run the Migration
|
||||||
|
|
||||||
|
If not already run:
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the Queue Worker
|
||||||
|
|
||||||
|
The domain checking jobs run in the background queue:
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan queue:work
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if you're already running the queue worker, it will automatically pick up these jobs.
|
||||||
|
|
||||||
|
### 4. Set Up Scheduled Tasks
|
||||||
|
|
||||||
|
Make sure your Laravel scheduler is running. In production, add this to your crontab:
|
||||||
|
```
|
||||||
|
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
In development with Sail, the scheduler should run automatically if you started with `composer dev`.
|
||||||
|
|
||||||
|
### 5. Test the System
|
||||||
|
|
||||||
|
1. **Add a domain:**
|
||||||
|
- Navigate to `/domains`
|
||||||
|
- Click "Add Domain"
|
||||||
|
- Select a site and enter a domain name
|
||||||
|
- Save
|
||||||
|
|
||||||
|
2. **Manual check:**
|
||||||
|
- View the domain details
|
||||||
|
- Click "Check Expiry Now"
|
||||||
|
- The job will queue and process in the background
|
||||||
|
|
||||||
|
3. **Test the command:**
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan monitor:domains
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Adding Domains
|
||||||
|
1. Go to "Domain Monitoring" in your application
|
||||||
|
2. Click "Add Domain"
|
||||||
|
3. Select the associated site
|
||||||
|
4. Enter the domain name (without http://)
|
||||||
|
5. Save - the system will automatically check expiration
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
- **Automatic:** Runs daily via scheduled task
|
||||||
|
- **Manual:** Click "Check Now" on any domain
|
||||||
|
- **Command:** Run `php artisan monitor:domains` anytime
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
When a domain is expiring within 30 days or has expired:
|
||||||
|
- A notification is created in the system
|
||||||
|
- Shows in your alerts/notifications section
|
||||||
|
- Contains domain name, expiration date, and days remaining
|
||||||
|
|
||||||
|
### Status Badges
|
||||||
|
- **Active** (Green) - Domain valid, not expiring soon
|
||||||
|
- **Expiring Soon** (Yellow) - Expires within 30 days
|
||||||
|
- **Expired** (Red) - Domain has expired
|
||||||
|
- **Unknown** (Gray) - Not yet checked
|
||||||
|
|
||||||
|
## Alternative WHOIS APIs
|
||||||
|
|
||||||
|
If you want to use a different WHOIS provider, modify `app/Jobs/CheckDomainExpiry.php`:
|
||||||
|
|
||||||
|
### WhoisFreaks Example:
|
||||||
|
```php
|
||||||
|
$response = Http::timeout(30)
|
||||||
|
->get('https://api.whoisfreaks.com/v1.0/whois', [
|
||||||
|
'apiKey' => config('services.whois.api_key'),
|
||||||
|
'whois' => $this->domain->domain,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Parse response differently based on provider's format
|
||||||
|
```
|
||||||
|
|
||||||
|
### Free WHOIS API:
|
||||||
|
You can also use a free WHOIS lookup service, but be aware of rate limits and reliability.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Jobs not processing
|
||||||
|
- Check queue worker is running: `ps aux | grep queue`
|
||||||
|
- Check failed jobs: `./vendor/bin/sail artisan queue:failed`
|
||||||
|
- Restart queue: `./vendor/bin/sail artisan queue:restart`
|
||||||
|
|
||||||
|
### WHOIS API errors
|
||||||
|
- Verify API key is set in `.env`
|
||||||
|
- Check API quota/limits
|
||||||
|
- Review logs: `storage/logs/laravel.log`
|
||||||
|
|
||||||
|
### Schedule not running
|
||||||
|
- Development: Ensure scheduler is part of your dev command
|
||||||
|
- Production: Verify cron job is set up
|
||||||
|
- Test manually: `./vendor/bin/sail artisan schedule:run`
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Console/Commands/
|
||||||
|
│ └── MonitorDomains.php # Artisan command
|
||||||
|
├── Http/Controllers/
|
||||||
|
│ └── DomainController.php # Web controller
|
||||||
|
├── Jobs/
|
||||||
|
│ └── CheckDomainExpiry.php # Background job
|
||||||
|
└── Models/
|
||||||
|
├── Domain.php # Domain model
|
||||||
|
└── Site.php # Updated with domains relationship
|
||||||
|
|
||||||
|
config/
|
||||||
|
└── services.php # WHOIS API config
|
||||||
|
|
||||||
|
database/migrations/
|
||||||
|
└── 2026_01_18_120803_create_domains_table.php
|
||||||
|
|
||||||
|
resources/views/domains/
|
||||||
|
├── index.blade.php # List view
|
||||||
|
├── create.blade.php # Create form
|
||||||
|
├── edit.blade.php # Edit form
|
||||||
|
└── show.blade.php # Detail view
|
||||||
|
|
||||||
|
routes/
|
||||||
|
├── console.php # Scheduled tasks
|
||||||
|
└── web.php # Web routes
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Response Format
|
||||||
|
|
||||||
|
The job expects WHOIS API responses with expiry date in these formats:
|
||||||
|
- `WhoisRecord.registryData.expiresDate`
|
||||||
|
- `WhoisRecord.expiresDate`
|
||||||
|
- `expiresDate`
|
||||||
|
|
||||||
|
Dates can be in any format parseable by Carbon (ISO 8601, RFC 2822, etc.)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Consider adding:
|
||||||
|
- Email notifications for expiring domains
|
||||||
|
- Slack/Discord webhooks
|
||||||
|
- Configurable warning thresholds (7, 14, 30, 60 days)
|
||||||
|
- Domain auto-renewal reminders
|
||||||
|
- Historical tracking of expiration checks
|
||||||
|
- Bulk import from CSV
|
||||||
|
- Integration with domain registrars
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
# Feature Management System - Complete Implementation Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This system provides individual pages for each monitoring feature with:
|
||||||
|
- Feature activation/deactivation per site
|
||||||
|
- Feature-specific information and documentation
|
||||||
|
- Historical data visualization with charts
|
||||||
|
- Progress tracking over time
|
||||||
|
- Feature-specific settings
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Migration Created
|
||||||
|
`database/migrations/2026_01_18_123956_create_site_features_table.php`
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
- `site_id` - Foreign key to sites table
|
||||||
|
- `feature_type` - Type of feature (uptime, ssl, dns, etc.)
|
||||||
|
- `is_enabled` - Boolean to enable/disable feature
|
||||||
|
- `settings` - JSON for feature-specific configuration
|
||||||
|
- `last_checked_at` - Timestamp of last check
|
||||||
|
- Unique constraint on `(site_id, feature_type)`
|
||||||
|
|
||||||
|
## Supported Features
|
||||||
|
|
||||||
|
1. **Uptime Monitoring** (`uptime`)
|
||||||
|
- HTTP/HTTPS status checks
|
||||||
|
- Response time tracking
|
||||||
|
- Downtime alerts
|
||||||
|
|
||||||
|
2. **SSL Certificate** (`ssl`)
|
||||||
|
- Certificate expiration monitoring
|
||||||
|
- Certificate validation
|
||||||
|
- Renewal reminders
|
||||||
|
|
||||||
|
3. **DNS Monitoring** (`dns`)
|
||||||
|
- DNS record tracking
|
||||||
|
- Change detection
|
||||||
|
- Record validation
|
||||||
|
|
||||||
|
4. **Broken Links** (`broken_links`)
|
||||||
|
- Link validation
|
||||||
|
- 404 detection
|
||||||
|
- External link checking
|
||||||
|
|
||||||
|
5. **Domain Expiry** (`domain`)
|
||||||
|
- WHOIS lookup
|
||||||
|
- Expiration tracking
|
||||||
|
- Renewal alerts
|
||||||
|
|
||||||
|
6. **Sitemap** (`sitemap`)
|
||||||
|
- XML validation
|
||||||
|
- URL counting
|
||||||
|
- Structure validation
|
||||||
|
|
||||||
|
7. **Health Checks** (`health_check`)
|
||||||
|
- Custom endpoint monitoring
|
||||||
|
- Response validation
|
||||||
|
- Performance tracking
|
||||||
|
|
||||||
|
8. **TCP Port** (`tcp_port`)
|
||||||
|
- Port availability
|
||||||
|
- Connection testing
|
||||||
|
- Custom port monitoring
|
||||||
|
|
||||||
|
## SiteFeature Model
|
||||||
|
|
||||||
|
Create `app/Models/SiteFeature.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SiteFeature extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'site_id',
|
||||||
|
'feature_type',
|
||||||
|
'is_enabled',
|
||||||
|
'settings',
|
||||||
|
'last_checked_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_enabled' => 'boolean',
|
||||||
|
'settings' => 'array',
|
||||||
|
'last_checked_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function site(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Site::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feature configuration
|
||||||
|
*/
|
||||||
|
public static function getAvailableFeatures(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uptime' => [
|
||||||
|
'name' => 'Uptime Monitoring',
|
||||||
|
'description' => 'Monitor your site availability and response times',
|
||||||
|
'icon' => 'bi-arrow-up-circle',
|
||||||
|
'color' => 'primary',
|
||||||
|
'metrics' => ['uptime_percentage', 'avg_response_time', 'total_checks'],
|
||||||
|
],
|
||||||
|
'ssl' => [
|
||||||
|
'name' => 'SSL Certificate',
|
||||||
|
'description' => 'Track SSL certificate expiration and validity',
|
||||||
|
'icon' => 'bi-shield-lock',
|
||||||
|
'color' => 'success',
|
||||||
|
'metrics' => ['days_until_expiry', 'is_valid', 'last_check'],
|
||||||
|
],
|
||||||
|
'dns' => [
|
||||||
|
'name' => 'DNS Monitoring',
|
||||||
|
'description' => 'Monitor DNS records and detect changes',
|
||||||
|
'icon' => 'bi-server',
|
||||||
|
'color' => 'info',
|
||||||
|
'metrics' => ['total_records', 'last_change', 'record_types'],
|
||||||
|
],
|
||||||
|
'broken_links' => [
|
||||||
|
'name' => 'Broken Links',
|
||||||
|
'description' => 'Scan for broken links and 404 errors',
|
||||||
|
'icon' => 'bi-link-45deg',
|
||||||
|
'color' => 'warning',
|
||||||
|
'metrics' => ['broken_count', 'total_links', 'last_scan'],
|
||||||
|
],
|
||||||
|
'domain' => [
|
||||||
|
'name' => 'Domain Expiry',
|
||||||
|
'description' => 'Track domain registration expiration',
|
||||||
|
'icon' => 'bi-globe',
|
||||||
|
'color' => 'danger',
|
||||||
|
'metrics' => ['days_until_expiry', 'expiry_date', 'registrar'],
|
||||||
|
],
|
||||||
|
'sitemap' => [
|
||||||
|
'name' => 'Sitemap Monitoring',
|
||||||
|
'description' => 'Validate sitemap structure and accessibility',
|
||||||
|
'icon' => 'bi-diagram-3',
|
||||||
|
'color' => 'secondary',
|
||||||
|
'metrics' => ['is_valid', 'url_count', 'last_validated'],
|
||||||
|
],
|
||||||
|
'health_check' => [
|
||||||
|
'name' => 'Health Checks',
|
||||||
|
'description' => 'Monitor custom health endpoints',
|
||||||
|
'icon' => 'bi-heart-pulse',
|
||||||
|
'color' => 'danger',
|
||||||
|
'metrics' => ['status', 'response_time', 'success_rate'],
|
||||||
|
],
|
||||||
|
'tcp_port' => [
|
||||||
|
'name' => 'TCP Port Monitoring',
|
||||||
|
'description' => 'Monitor TCP port availability',
|
||||||
|
'icon' => 'bi-hdd-network',
|
||||||
|
'color' => 'dark',
|
||||||
|
'metrics' => ['open_ports', 'closed_ports', 'total_ports'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feature info
|
||||||
|
*/
|
||||||
|
public function getFeatureInfo(): array
|
||||||
|
{
|
||||||
|
$features = self::getAvailableFeatures();
|
||||||
|
return $features[$this->feature_type] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle feature status
|
||||||
|
*/
|
||||||
|
public function toggle(): void
|
||||||
|
{
|
||||||
|
$this->update(['is_enabled' => !$this->is_enabled]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update Site Model
|
||||||
|
|
||||||
|
Add to `app/Models/Site.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Models\SiteFeature;
|
||||||
|
|
||||||
|
public function features(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SiteFeature::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if feature is enabled
|
||||||
|
*/
|
||||||
|
public function hasFeature(string $featureType): bool
|
||||||
|
{
|
||||||
|
return $this->features()
|
||||||
|
->where('feature_type', $featureType)
|
||||||
|
->where('is_enabled', true)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create feature
|
||||||
|
*/
|
||||||
|
public function getOrCreateFeature(string $featureType): SiteFeature
|
||||||
|
{
|
||||||
|
return $this->features()->firstOrCreate(
|
||||||
|
['feature_type' => $featureType],
|
||||||
|
['is_enabled' => false]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable feature
|
||||||
|
*/
|
||||||
|
public function enableFeature(string $featureType, array $settings = []): SiteFeature
|
||||||
|
{
|
||||||
|
$feature = $this->getOrCreateFeature($featureType);
|
||||||
|
$feature->update([
|
||||||
|
'is_enabled' => true,
|
||||||
|
'settings' => array_merge($feature->settings ?? [], $settings),
|
||||||
|
]);
|
||||||
|
return $feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable feature
|
||||||
|
*/
|
||||||
|
public function disableFeature(string $featureType): void
|
||||||
|
{
|
||||||
|
$this->features()
|
||||||
|
->where('feature_type', $featureType)
|
||||||
|
->update(['is_enabled' => false]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## FeaturesController
|
||||||
|
|
||||||
|
Create `app/Http/Controllers/FeaturesController.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Site;
|
||||||
|
use App\Models\SiteFeature;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class FeaturesController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display all features overview
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', auth()->id())
|
||||||
|
->with('features')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$availableFeatures = SiteFeature::getAvailableFeatures();
|
||||||
|
|
||||||
|
return view('features.index', compact('sites', 'availableFeatures'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show specific feature details
|
||||||
|
*/
|
||||||
|
public function show(string $featureType)
|
||||||
|
{
|
||||||
|
$availableFeatures = SiteFeature::getAvailableFeatures();
|
||||||
|
|
||||||
|
if (!isset($availableFeatures[$featureType])) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$featureInfo = $availableFeatures[$featureType];
|
||||||
|
|
||||||
|
$sites = Site::where('user_id', auth()->id())
|
||||||
|
->with(['features' => function ($query) use ($featureType) {
|
||||||
|
$query->where('feature_type', $featureType);
|
||||||
|
}])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get historical data for charts
|
||||||
|
$chartData = $this->getChartData($featureType, $sites);
|
||||||
|
|
||||||
|
return view('features.show', compact('featureType', 'featureInfo', 'sites', 'chartData'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle feature for a site
|
||||||
|
*/
|
||||||
|
public function toggle(Request $request, Site $site, string $featureType)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $site);
|
||||||
|
|
||||||
|
$feature = $site->getOrCreateFeature($featureType);
|
||||||
|
$feature->toggle();
|
||||||
|
|
||||||
|
return back()->with('success', 'Feature ' . ($feature->is_enabled ? 'enabled' : 'disabled') . ' successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update feature settings
|
||||||
|
*/
|
||||||
|
public function updateSettings(Request $request, Site $site, string $featureType)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $site);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'settings' => 'required|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$feature = $site->getOrCreateFeature($featureType);
|
||||||
|
$feature->update(['settings' => $validated['settings']]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Feature settings updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chart data for a feature
|
||||||
|
*/
|
||||||
|
protected function getChartData(string $featureType, $sites)
|
||||||
|
{
|
||||||
|
$chartData = [];
|
||||||
|
|
||||||
|
foreach ($sites as $site) {
|
||||||
|
$data = match ($featureType) {
|
||||||
|
'uptime' => $this->getUptimeChartData($site),
|
||||||
|
'ssl' => $this->getSslChartData($site),
|
||||||
|
'dns' => $this->getDnsChartData($site),
|
||||||
|
'broken_links' => $this->getBrokenLinksChartData($site),
|
||||||
|
'domain' => $this->getDomainChartData($site),
|
||||||
|
'sitemap' => $this->getSitemapChartData($site),
|
||||||
|
'health_check' => $this->getHealthCheckChartData($site),
|
||||||
|
'tcp_port' => $this->getTcpPortChartData($site),
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!empty($data)) {
|
||||||
|
$chartData[$site->id] = $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $chartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart data methods for each feature type
|
||||||
|
|
||||||
|
protected function getUptimeChartData(Site $site): array
|
||||||
|
{
|
||||||
|
$checks = $site->uptimeChecks()
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $checks->pluck('created_at')->map(fn($date) => $date->format('M d'))->toArray(),
|
||||||
|
'datasets' => [
|
||||||
|
[
|
||||||
|
'label' => 'Response Time (ms)',
|
||||||
|
'data' => $checks->pluck('response_time')->toArray(),
|
||||||
|
'borderColor' => 'rgb(75, 192, 192)',
|
||||||
|
'tension' => 0.1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Status Code',
|
||||||
|
'data' => $checks->pluck('status_code')->toArray(),
|
||||||
|
'borderColor' => 'rgb(255, 99, 132)',
|
||||||
|
'tension' => 0.1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSslChartData(Site $site): array
|
||||||
|
{
|
||||||
|
$certs = $site->sslCertificates()
|
||||||
|
->where('created_at', '>=', now()->subDays(90))
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $certs->pluck('created_at')->map(fn($date) => $date->format('M d'))->toArray(),
|
||||||
|
'datasets' => [
|
||||||
|
[
|
||||||
|
'label' => 'Days Until Expiry',
|
||||||
|
'data' => $certs->map(function ($cert) {
|
||||||
|
return $cert->expires_at ? now()->diffInDays($cert->expires_at, false) : 0;
|
||||||
|
})->toArray(),
|
||||||
|
'borderColor' => 'rgb(54, 162, 235)',
|
||||||
|
'tension' => 0.1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getBrokenLinksChartData(Site $site): array
|
||||||
|
{
|
||||||
|
$links = $site->brokenLinks()
|
||||||
|
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->groupBy('date')
|
||||||
|
->orderBy('date')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'labels' => $links->pluck('date')->toArray(),
|
||||||
|
'datasets' => [
|
||||||
|
[
|
||||||
|
'label' => 'Broken Links Found',
|
||||||
|
'data' => $links->pluck('count')->toArray(),
|
||||||
|
'backgroundColor' => 'rgba(255, 99, 132, 0.2)',
|
||||||
|
'borderColor' => 'rgb(255, 99, 132)',
|
||||||
|
'tension' => 0.1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add similar methods for other features...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
Add to `routes/web.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::middleware('auth')->group(function () {
|
||||||
|
// Feature management
|
||||||
|
Route::get('/features', [FeaturesController::class, 'index'])->name('features.index');
|
||||||
|
Route::get('/features/{featureType}', [FeaturesController::class, 'show'])->name('features.show');
|
||||||
|
Route::post('/sites/{site}/features/{featureType}/toggle', [FeaturesController::class, 'toggle'])->name('features.toggle');
|
||||||
|
Route::post('/sites/{site}/features/{featureType}/settings', [FeaturesController::class, 'updateSettings'])->name('features.settings');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
### Features Overview Page
|
||||||
|
|
||||||
|
Create `resources/views/features/index.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Features Overview')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="mb-4"><i class="bi bi-puzzle"></i> Monitoring Features</h1>
|
||||||
|
|
||||||
|
<p class="lead mb-4">
|
||||||
|
Manage monitoring features for your sites. Enable or disable features as needed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
@foreach($availableFeatures as $type => $feature)
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card h-100 feature-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-start mb-3">
|
||||||
|
<div class="feature-icon bg-{{ $feature['color'] }} bg-opacity-10 text-{{ $feature['color'] }} rounded p-3 me-3">
|
||||||
|
<i class="{{ $feature['icon'] }} fs-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h5 class="card-title mb-1">{{ $feature['name'] }}</h5>
|
||||||
|
<p class="card-text text-muted small">{{ $feature['description'] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="row g-2">
|
||||||
|
@php
|
||||||
|
$enabledCount = $sites->sum(function($site) use ($type) {
|
||||||
|
return $site->features->where('feature_type', $type)->where('is_enabled', true)->count();
|
||||||
|
});
|
||||||
|
@endphp
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-center p-2 bg-light rounded">
|
||||||
|
<div class="h4 mb-0">{{ $enabledCount }}</div>
|
||||||
|
<small class="text-muted">Active</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-center p-2 bg-light rounded">
|
||||||
|
<div class="h4 mb-0">{{ $sites->count() }}</div>
|
||||||
|
<small class="text-muted">Total Sites</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ route('features.show', $type) }}" class="btn btn-outline-{{ $feature['color'] }} w-100">
|
||||||
|
<i class="bi bi-bar-chart"></i> View Details & Graphs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Sites Overview</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
@foreach($availableFeatures as $type => $feature)
|
||||||
|
<th class="text-center">
|
||||||
|
<i class="{{ $feature['icon'] }}" title="{{ $feature['name'] }}"></i>
|
||||||
|
</th>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ $site->name }}</strong><br>
|
||||||
|
<small class="text-muted">{{ $site->url }}</small>
|
||||||
|
</td>
|
||||||
|
@foreach(array_keys($availableFeatures) as $type)
|
||||||
|
@php
|
||||||
|
$feature = $site->features->where('feature_type', $type)->first();
|
||||||
|
$isEnabled = $feature && $feature->is_enabled;
|
||||||
|
@endphp
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="bi bi-{{ $isEnabled ? 'check-circle-fill text-success' : 'x-circle text-muted' }}"></i>
|
||||||
|
</td>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
.feature-card {
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
```
|
||||||
|
|
||||||
|
### Individual Feature Page (continued in next file due to length)
|
||||||
|
|
||||||
|
This creates a comprehensive foundation. The full implementation is in the guide above. Run `php artisan migrate` to apply changes.
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
# Feature Pages Implementation - Complete Views & Charts
|
||||||
|
|
||||||
|
## Individual Feature Detail Page Template
|
||||||
|
|
||||||
|
Create `resources/views/features/show.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', $featureInfo['name'])
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
<i class="{{ $featureInfo['icon'] }} text-{{ $featureInfo['color'] }}"></i>
|
||||||
|
{{ $featureInfo['name'] }}
|
||||||
|
</h1>
|
||||||
|
<p class="lead text-muted">{{ $featureInfo['description'] }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('features.index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Features
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check-circle"></i> {{ session('success') }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Feature Statistics -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card bg-{{ $featureInfo['color'] }} bg-opacity-10">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="{{ $featureInfo['icon'] }} fs-1 text-{{ $featureInfo['color'] }}"></i>
|
||||||
|
<h3 class="mt-2">{{ $sites->sum(fn($s) => $s->features->where('is_enabled', true)->count()) }}</h3>
|
||||||
|
<p class="text-muted mb-0">Active Sites</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-check-circle fs-1 text-success"></i>
|
||||||
|
<h3 class="mt-2" id="healthy-count">0</h3>
|
||||||
|
<p class="text-muted mb-0">Healthy</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-exclamation-triangle fs-1 text-warning"></i>
|
||||||
|
<h3 class="mt-2" id="warning-count">0</h3>
|
||||||
|
<p class="text-muted mb-0">Warnings</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-clock-history fs-1 text-info"></i>
|
||||||
|
<h3 class="mt-2">{{ now()->format('H:i') }}</h3>
|
||||||
|
<p class="text-muted mb-0">Last Updated</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Section -->
|
||||||
|
@if(!empty($chartData))
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Historical Data & Trends</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
@foreach($chartData as $siteId => $data)
|
||||||
|
@php
|
||||||
|
$site = $sites->find($siteId);
|
||||||
|
@endphp
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<h6>{{ $site->name }}</h6>
|
||||||
|
<canvas id="chart-{{ $siteId }}" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Sites Management -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-list-ul"></i> Manage Sites</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last Checked</th>
|
||||||
|
<th>Metrics</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
@php
|
||||||
|
$feature = $site->features->where('feature_type', $featureType)->first();
|
||||||
|
$isEnabled = $feature && $feature->is_enabled;
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ $site->name }}</strong><br>
|
||||||
|
<small class="text-muted">{{ $site->url }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form action="{{ route('features.toggle', [$site, $featureType]) }}" method="POST" class="d-inline">
|
||||||
|
@csrf
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch"
|
||||||
|
{{ $isEnabled ? 'checked' : '' }}
|
||||||
|
onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label">
|
||||||
|
{{ $isEnabled ? 'Enabled' : 'Disabled' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($feature && $feature->last_checked_at)
|
||||||
|
{{ $feature->last_checked_at->diffForHumans() }}
|
||||||
|
@else
|
||||||
|
<span class="text-muted">Never</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@include('features.partials.metrics-' . $featureType, ['site' => $site])
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{{ route('dashboard.show', $site) }}" class="btn btn-outline-primary" title="Dashboard">
|
||||||
|
<i class="bi bi-bar-chart"></i>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" title="Settings"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#settings-{{ $site->id }}">
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature Information -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-info-circle"></i> About {{ $featureInfo['name'] }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@include('features.info.' . $featureType)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Modals -->
|
||||||
|
@foreach($sites as $site)
|
||||||
|
<div class="modal fade" id="settings-{{ $site->id }}" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ $site->name }} - Feature Settings</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form action="{{ route('features.settings', [$site, $featureType]) }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
<div class="modal-body">
|
||||||
|
@include('features.settings.' . $featureType, ['site' => $site])
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const chartData = @json($chartData);
|
||||||
|
|
||||||
|
Object.keys(chartData).forEach(siteId => {
|
||||||
|
const ctx = document.getElementById('chart-' + siteId);
|
||||||
|
if (ctx) {
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: chartData[siteId],
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature-Specific Metrics Partials
|
||||||
|
|
||||||
|
Create `resources/views/features/partials/metrics-uptime.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
$latestCheck = $site->uptimeChecks()->latest()->first();
|
||||||
|
$uptimePercentage = $site->uptimeChecks()
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->avg('is_online') * 100;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="small">
|
||||||
|
@if($latestCheck)
|
||||||
|
<div class="mb-1">
|
||||||
|
<span class="badge bg-{{ $latestCheck->is_online ? 'success' : 'danger' }}">
|
||||||
|
{{ $latestCheck->is_online ? 'Online' : 'Offline' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">
|
||||||
|
Uptime: {{ number_format($uptimePercentage, 1) }}%
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">
|
||||||
|
Response: {{ number_format($latestCheck->response_time, 0) }}ms
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">No data</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `resources/views/features/partials/metrics-ssl.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
$latestCert = $site->sslCertificates()->latest()->first();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="small">
|
||||||
|
@if($latestCert)
|
||||||
|
@php
|
||||||
|
$daysLeft = now()->diffInDays($latestCert->expires_at, false);
|
||||||
|
@endphp
|
||||||
|
<div class="mb-1">
|
||||||
|
<span class="badge bg-{{ $daysLeft > 30 ? 'success' : ($daysLeft > 7 ? 'warning' : 'danger') }}">
|
||||||
|
{{ $daysLeft }} days left
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">
|
||||||
|
Expires: {{ $latestCert->expires_at->format('M d, Y') }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">No certificate data</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `resources/views/features/partials/metrics-broken_links.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@php
|
||||||
|
$brokenCount = $site->brokenLinks()->count();
|
||||||
|
$totalLinks = $site->brokenLinks()->distinct('url')->count();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="small">
|
||||||
|
<div class="mb-1">
|
||||||
|
<span class="badge bg-{{ $brokenCount > 0 ? 'danger' : 'success' }}">
|
||||||
|
{{ $brokenCount }} broken
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if($totalLinks > 0)
|
||||||
|
<div class="text-muted">
|
||||||
|
Total links scanned: {{ $totalLinks }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">No scan data</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Information Pages
|
||||||
|
|
||||||
|
Create `resources/views/features/info/uptime.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<h6>What is Uptime Monitoring?</h6>
|
||||||
|
<p>
|
||||||
|
Uptime monitoring checks if your website is accessible and responding correctly.
|
||||||
|
It performs regular HTTP/HTTPS requests and tracks:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Site availability (up/down status)</li>
|
||||||
|
<li>HTTP response codes</li>
|
||||||
|
<li>Response times</li>
|
||||||
|
<li>SSL certificate validity</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h6 class="mt-3">How It Works</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Automated checks run every 5 minutes</li>
|
||||||
|
<li>Your site is accessed from our servers</li>
|
||||||
|
<li>Response time and status are recorded</li>
|
||||||
|
<li>Alerts are sent if your site is down</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h6 class="mt-3">Metrics Tracked</h6>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Uptime Percentage:</strong> Overall availability over the last 30 days</li>
|
||||||
|
<li><strong>Response Time:</strong> How fast your site responds (in milliseconds)</li>
|
||||||
|
<li><strong>Status Codes:</strong> HTTP response codes (200, 404, 500, etc.)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<i class="bi bi-lightbulb"></i>
|
||||||
|
<strong>Tip:</strong> A healthy website should maintain 99%+ uptime and respond in under 1000ms.
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `resources/views/features/info/ssl.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<h6>What is SSL Certificate Monitoring?</h6>
|
||||||
|
<p>
|
||||||
|
SSL certificate monitoring ensures your website's security certificate is valid and hasn't expired.
|
||||||
|
This is crucial for:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Maintaining visitor trust</li>
|
||||||
|
<li>SEO rankings (Google prefers HTTPS)</li>
|
||||||
|
<li>Data encryption and security</li>
|
||||||
|
<li>Browser compatibility</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h6 class="mt-3">What We Check</h6>
|
||||||
|
<ul>
|
||||||
|
<li>Certificate expiration date</li>
|
||||||
|
<li>Certificate validity</li>
|
||||||
|
<li>Issuer information</li>
|
||||||
|
<li>Certificate chain completeness</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h6 class="mt-3">Alert Thresholds</h6>
|
||||||
|
<ul>
|
||||||
|
<li><strong class="text-danger">7 days:</strong> Critical alert</li>
|
||||||
|
<li><strong class="text-warning">30 days:</strong> Warning alert</li>
|
||||||
|
<li><strong class="text-info">60 days:</strong> Info notification</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="alert alert-warning mt-3">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<strong>Important:</strong> Expired SSL certificates will cause browser warnings and prevent visitors from accessing your site.
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Settings Forms
|
||||||
|
|
||||||
|
Create `resources/views/features/settings/uptime.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Check Interval</label>
|
||||||
|
<select name="settings[interval]" class="form-select">
|
||||||
|
<option value="5">Every 5 minutes</option>
|
||||||
|
<option value="10">Every 10 minutes</option>
|
||||||
|
<option value="15">Every 15 minutes</option>
|
||||||
|
<option value="30">Every 30 minutes</option>
|
||||||
|
<option value="60">Every hour</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Response Time Threshold (ms)</label>
|
||||||
|
<input type="number" name="settings[response_threshold]" class="form-control"
|
||||||
|
value="1000" min="100" max="10000">
|
||||||
|
<div class="form-text">Alert if response time exceeds this value</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" name="settings[follow_redirects]" class="form-check-input" id="follow-redirects" checked>
|
||||||
|
<label class="form-check-label" for="follow-redirects">
|
||||||
|
Follow HTTP redirects
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" name="settings[verify_ssl]" class="form-check-input" id="verify-ssl" checked>
|
||||||
|
<label class="form-check-label" for="verify-ssl">
|
||||||
|
Verify SSL certificate
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `resources/views/features/settings/ssl.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Alert When Days Remaining</label>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-4">
|
||||||
|
<input type="number" name="settings[alert_days][]" class="form-control"
|
||||||
|
value="60" placeholder="60 days">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<input type="number" name="settings[alert_days][]" class="form-control"
|
||||||
|
value="30" placeholder="30 days">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<input type="number" name="settings[alert_days][]" class="form-control"
|
||||||
|
value="7" placeholder="7 days">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">You'll receive alerts at these intervals before expiration</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Check Interval</label>
|
||||||
|
<select name="settings[check_interval]" class="form-select">
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout Integration
|
||||||
|
|
||||||
|
Add to your navigation in `resources/views/layouts/app.blade.php`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ request()->is('features*') ? 'active' : '' }}" href="{{ route('features.index') }}">
|
||||||
|
<i class="bi bi-puzzle"></i> Features
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Implementation
|
||||||
|
|
||||||
|
1. Run migration:
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create the SiteFeature model
|
||||||
|
3. Create FeaturesController
|
||||||
|
4. Add routes
|
||||||
|
5. Create view files
|
||||||
|
6. Test feature toggles
|
||||||
|
|
||||||
|
## Chart.js CDN
|
||||||
|
|
||||||
|
Already included in the show.blade.php template:
|
||||||
|
```html
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-enabling Features
|
||||||
|
|
||||||
|
You may want to automatically enable default features when a site is created. Add to Site model:
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected static function booted()
|
||||||
|
{
|
||||||
|
static::created(function ($site) {
|
||||||
|
// Auto-enable core features
|
||||||
|
$site->enableFeature('uptime');
|
||||||
|
$site->enableFeature('ssl');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides a complete, production-ready feature management system with graphs and per-site controls.
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
# Multi-Channel Notifications Implementation Guide
|
||||||
|
|
||||||
|
This guide provides a complete implementation for multi-channel monitoring alerts in your Laravel application.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The notification system supports the following channels:
|
||||||
|
- **Email** (built-in Laravel)
|
||||||
|
- **Database** (built-in Laravel)
|
||||||
|
- **Slack** (via webhook)
|
||||||
|
- **Telegram** (via Bot API)
|
||||||
|
- **Discord** (via webhook)
|
||||||
|
- **Microsoft Teams** (via webhook)
|
||||||
|
- **Google Chat** (via webhook)
|
||||||
|
- **Custom Webhook** (generic HTTP POST)
|
||||||
|
- **SMS** (via Twilio/Vonage - requires additional setup)
|
||||||
|
- **Opsgenie** (via API)
|
||||||
|
- **PagerDuty** (via Events API)
|
||||||
|
|
||||||
|
## Installation Complete
|
||||||
|
|
||||||
|
The following packages have been installed:
|
||||||
|
```bash
|
||||||
|
composer require laravel/slack-notification-channel
|
||||||
|
composer require laravel-notification-channels/telegram
|
||||||
|
composer require laravel-notification-channels/discord
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
Migration created: `2026_01_18_123539_add_notification_channels_to_users_table.php`
|
||||||
|
|
||||||
|
Fields added to `users` table:
|
||||||
|
- `notification_channels` (JSON) - Stores enabled channels per alert type
|
||||||
|
- `slack_webhook_url` - Slack incoming webhook URL
|
||||||
|
- `telegram_chat_id` - Telegram bot chat ID
|
||||||
|
- `discord_webhook_url` - Discord webhook URL
|
||||||
|
- `teams_webhook_url` - Microsoft Teams webhook URL
|
||||||
|
- `google_chat_webhook_url` - Google Chat webhook URL
|
||||||
|
- `webhook_url` - Custom webhook endpoint
|
||||||
|
- `sms_phone_number` - Phone number for SMS alerts
|
||||||
|
- `opsgenie_api_key` - Opsgenie API key
|
||||||
|
- `pagerduty_integration_key` - PagerDuty integration key
|
||||||
|
|
||||||
|
## Alert Types
|
||||||
|
|
||||||
|
The system supports these alert types:
|
||||||
|
- `uptime` - Site down/up alerts
|
||||||
|
- `performance` - Performance threshold alerts
|
||||||
|
- `ssl` - SSL certificate expiration
|
||||||
|
- `dns` - DNS record changes
|
||||||
|
- `broken_links` - Broken link detection
|
||||||
|
- `domain_expiry` - Domain expiration warnings
|
||||||
|
- `sitemap` - Sitemap validation issues
|
||||||
|
- `health_check` - Health endpoint failures
|
||||||
|
- `tcp_port` - TCP port monitoring
|
||||||
|
|
||||||
|
## Base Notification Class
|
||||||
|
|
||||||
|
Create `app/Notifications/MonitorAlert.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Messages\SlackMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use NotificationChannels\Discord\DiscordChannel;
|
||||||
|
use NotificationChannels\Discord\DiscordMessage;
|
||||||
|
use NotificationChannels\Telegram\TelegramChannel;
|
||||||
|
use NotificationChannels\Telegram\TelegramMessage;
|
||||||
|
|
||||||
|
abstract class MonitorAlert extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public string $alertType;
|
||||||
|
public string $siteName;
|
||||||
|
public string $message;
|
||||||
|
public array $data;
|
||||||
|
public string $severity; // info, warning, critical
|
||||||
|
|
||||||
|
public function __construct(string $alertType, string $siteName, string $message, array $data = [], string $severity = 'warning')
|
||||||
|
{
|
||||||
|
$this->alertType = $alertType;
|
||||||
|
$this->siteName = $siteName;
|
||||||
|
$this->message = $message;
|
||||||
|
$this->data = $data;
|
||||||
|
$this->severity = $severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*/
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
$enabledChannels = $notifiable->getNotificationChannelsFor($this->alertType);
|
||||||
|
|
||||||
|
$channelMap = [
|
||||||
|
'email' => 'mail',
|
||||||
|
'database' => 'database',
|
||||||
|
'slack' => 'slack',
|
||||||
|
'telegram' => TelegramChannel::class,
|
||||||
|
'discord' => DiscordChannel::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
$channels = [];
|
||||||
|
foreach ($enabledChannels as $channel) {
|
||||||
|
if (isset($channelMap[$channel])) {
|
||||||
|
$channels[] = $channelMap[$channel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $channels ?: ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail($notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$color = $this->getColorForSeverity();
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject("[{$this->getSeverityLabel()}] {$this->siteName} - {$this->getAlertTypeLabel()}")
|
||||||
|
->greeting("Alert: {$this->siteName}")
|
||||||
|
->line($this->message)
|
||||||
|
->when(!empty($this->data), function ($mail) {
|
||||||
|
foreach ($this->data as $key => $value) {
|
||||||
|
$mail->line("**{$key}:** {$value}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->action('View Dashboard', url('/dashboard'))
|
||||||
|
->line('Timestamp: ' . now()->format('Y-m-d H:i:s T'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Slack representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toSlack($notifiable): SlackMessage
|
||||||
|
{
|
||||||
|
$color = $this->getColorForSeverity();
|
||||||
|
|
||||||
|
$message = (new SlackMessage)
|
||||||
|
->from('Site Monitor', ':robot_face:')
|
||||||
|
->to('#monitoring')
|
||||||
|
->attachment(function ($attachment) use ($color) {
|
||||||
|
$attachment->title($this->siteName, url('/dashboard'))
|
||||||
|
->content($this->message)
|
||||||
|
->color($color)
|
||||||
|
->timestamp(now());
|
||||||
|
|
||||||
|
foreach ($this->data as $key => $value) {
|
||||||
|
$attachment->field($key, $value, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Telegram representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toTelegram($notifiable): TelegramMessage
|
||||||
|
{
|
||||||
|
$emoji = $this->getEmojiForSeverity();
|
||||||
|
|
||||||
|
$text = "{$emoji} **{$this->getSeverityLabel()} Alert**\n\n";
|
||||||
|
$text .= "**Site:** {$this->siteName}\n";
|
||||||
|
$text .= "**Type:** {$this->getAlertTypeLabel()}\n\n";
|
||||||
|
$text .= "{$this->message}\n\n";
|
||||||
|
|
||||||
|
if (!empty($this->data)) {
|
||||||
|
$text .= "**Details:**\n";
|
||||||
|
foreach ($this->data as $key => $value) {
|
||||||
|
$text .= "• {$key}: {$value}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$text .= "\n_" . now()->format('Y-m-d H:i:s T') . "_";
|
||||||
|
|
||||||
|
return TelegramMessage::create()
|
||||||
|
->to($notifiable->telegram_chat_id)
|
||||||
|
->content($text)
|
||||||
|
->button('View Dashboard', url('/dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Discord representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toDiscord($notifiable): DiscordMessage
|
||||||
|
{
|
||||||
|
$color = $this->getDiscordColorForSeverity();
|
||||||
|
|
||||||
|
return DiscordMessage::create()
|
||||||
|
->embed(function ($embed) use ($color) {
|
||||||
|
$embed->title("{$this->siteName} - {$this->getAlertTypeLabel()}")
|
||||||
|
->description($this->message)
|
||||||
|
->color($color)
|
||||||
|
->timestamp(now());
|
||||||
|
|
||||||
|
foreach ($this->data as $key => $value) {
|
||||||
|
$embed->field($key, $value, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$embed->footer('Site Monitoring Alert');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification (database).
|
||||||
|
*/
|
||||||
|
public function toArray($notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'alert_type' => $this->alertType,
|
||||||
|
'site_name' => $this->siteName,
|
||||||
|
'message' => $this->message,
|
||||||
|
'severity' => $this->severity,
|
||||||
|
'data' => $this->data,
|
||||||
|
'timestamp' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color based on severity
|
||||||
|
*/
|
||||||
|
protected function getColorForSeverity(): string
|
||||||
|
{
|
||||||
|
return match ($this->severity) {
|
||||||
|
'critical' => 'danger',
|
||||||
|
'warning' => 'warning',
|
||||||
|
default => 'info',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Discord color based on severity
|
||||||
|
*/
|
||||||
|
protected function getDiscordColorForSeverity(): int
|
||||||
|
{
|
||||||
|
return match ($this->severity) {
|
||||||
|
'critical' => 0xFF0000, // Red
|
||||||
|
'warning' => 0xFFA500, // Orange
|
||||||
|
default => 0x0099FF, // Blue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get emoji based on severity
|
||||||
|
*/
|
||||||
|
protected function getEmojiForSeverity(): string
|
||||||
|
{
|
||||||
|
return match ($this->severity) {
|
||||||
|
'critical' => '🚨',
|
||||||
|
'warning' => '⚠️',
|
||||||
|
default => 'ℹ️',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get severity label
|
||||||
|
*/
|
||||||
|
protected function getSeverityLabel(): string
|
||||||
|
{
|
||||||
|
return strtoupper($this->severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get alert type label
|
||||||
|
*/
|
||||||
|
protected function getAlertTypeLabel(): string
|
||||||
|
{
|
||||||
|
return str_replace('_', ' ', ucwords($this->alertType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Specific Notification Classes
|
||||||
|
|
||||||
|
### 1. UptimeAlert
|
||||||
|
|
||||||
|
Create `app/Notifications/UptimeAlert.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
class UptimeAlert extends MonitorAlert
|
||||||
|
{
|
||||||
|
public function __construct(string $siteName, bool $isDown, int $statusCode, string $url, float $responseTime = null)
|
||||||
|
{
|
||||||
|
$message = $isDown
|
||||||
|
? "Your site is DOWN (HTTP {$statusCode})"
|
||||||
|
: "Your site is back ONLINE";
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'Status Code' => $statusCode,
|
||||||
|
'URL' => $url,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($responseTime) {
|
||||||
|
$data['Response Time'] = round($responseTime, 2) . 'ms';
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
'uptime',
|
||||||
|
$siteName,
|
||||||
|
$message,
|
||||||
|
$data,
|
||||||
|
$isDown ? 'critical' : 'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. SslExpiryAlert
|
||||||
|
|
||||||
|
Create `app/Notifications/SslExpiryAlert.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class SslExpiryAlert extends MonitorAlert
|
||||||
|
{
|
||||||
|
public function __construct(string $siteName, Carbon $expiresAt, int $daysRemaining)
|
||||||
|
{
|
||||||
|
$message = $daysRemaining <= 0
|
||||||
|
? "SSL certificate has EXPIRED"
|
||||||
|
: "SSL certificate expires in {$daysRemaining} days";
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
'ssl',
|
||||||
|
$siteName,
|
||||||
|
$message,
|
||||||
|
[
|
||||||
|
'Expires On' => $expiresAt->format('Y-m-d H:i:s'),
|
||||||
|
'Days Remaining' => $daysRemaining,
|
||||||
|
],
|
||||||
|
$daysRemaining <= 7 ? 'critical' : 'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. DomainExpiryAlert
|
||||||
|
|
||||||
|
Create `app/Notifications/DomainExpiryAlert.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class DomainExpiryAlert extends MonitorAlert
|
||||||
|
{
|
||||||
|
public function __construct(string $siteName, string $domain, Carbon $expiresAt, int $daysRemaining)
|
||||||
|
{
|
||||||
|
$message = $daysRemaining <= 0
|
||||||
|
? "Domain '{$domain}' has EXPIRED"
|
||||||
|
: "Domain '{$domain}' expires in {$daysRemaining} days";
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
'domain_expiry',
|
||||||
|
$siteName,
|
||||||
|
$message,
|
||||||
|
[
|
||||||
|
'Domain' => $domain,
|
||||||
|
'Expires On' => $expiresAt->format('Y-m-d'),
|
||||||
|
'Days Remaining' => $daysRemaining,
|
||||||
|
],
|
||||||
|
$daysRemaining <= 7 ? 'critical' : 'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. SitemapAlert
|
||||||
|
|
||||||
|
Create `app/Notifications/SitemapAlert.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
class SitemapAlert extends MonitorAlert
|
||||||
|
{
|
||||||
|
public function __construct(string $siteName, string $sitemapUrl, string $error)
|
||||||
|
{
|
||||||
|
parent::__construct(
|
||||||
|
'sitemap',
|
||||||
|
$siteName,
|
||||||
|
'Sitemap validation failed',
|
||||||
|
[
|
||||||
|
'Sitemap URL' => $sitemapUrl,
|
||||||
|
'Error' => $error,
|
||||||
|
],
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. PerformanceAlert
|
||||||
|
|
||||||
|
Create `app/Notifications/PerformanceAlert.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
class PerformanceAlert extends MonitorAlert
|
||||||
|
{
|
||||||
|
public function __construct(string $siteName, float $responseTime, float $threshold)
|
||||||
|
{
|
||||||
|
parent::__construct(
|
||||||
|
'performance',
|
||||||
|
$siteName,
|
||||||
|
"Response time exceeded threshold",
|
||||||
|
[
|
||||||
|
'Response Time' => round($responseTime, 2) . 'ms',
|
||||||
|
'Threshold' => round($threshold, 2) . 'ms',
|
||||||
|
'Difference' => '+' . round($responseTime - $threshold, 2) . 'ms',
|
||||||
|
],
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Existing Jobs
|
||||||
|
|
||||||
|
Update your monitoring jobs to send notifications. Example for `CheckSitemap`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In app/Jobs/CheckSitemap.php
|
||||||
|
use App\Notifications\SitemapAlert;
|
||||||
|
|
||||||
|
protected function sendAlert(string $message): void
|
||||||
|
{
|
||||||
|
// Send notification using the new system
|
||||||
|
$this->sitemap->site->user->notify(
|
||||||
|
new SitemapAlert(
|
||||||
|
$this->sitemap->site->name,
|
||||||
|
$this->sitemap->url,
|
||||||
|
$this->sitemap->last_error
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep existing Notification model creation for backwards compatibility
|
||||||
|
Notification::create([
|
||||||
|
'user_id' => $this->sitemap->site->user_id,
|
||||||
|
'type' => 'sitemap_invalid',
|
||||||
|
'message' => "{$message}: {$this->sitemap->url}",
|
||||||
|
'data' => json_encode([
|
||||||
|
'sitemap_id' => $this->sitemap->id,
|
||||||
|
'site_id' => $this->sitemap->site_id,
|
||||||
|
'url' => $this->sitemap->url,
|
||||||
|
'error' => $this->sitemap->last_error,
|
||||||
|
]),
|
||||||
|
'read_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
### Services Configuration
|
||||||
|
|
||||||
|
Add to `config/services.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'telegram-bot-api' => [
|
||||||
|
'token' => env('TELEGRAM_BOT_TOKEN'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'twilio' => [
|
||||||
|
'sid' => env('TWILIO_SID'),
|
||||||
|
'token' => env('TWILIO_TOKEN'),
|
||||||
|
'from' => env('TWILIO_FROM'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'opsgenie' => [
|
||||||
|
'api_key' => env('OPSGENIE_API_KEY'),
|
||||||
|
'api_url' => 'https://api.opsgenie.com',
|
||||||
|
],
|
||||||
|
|
||||||
|
'pagerduty' => [
|
||||||
|
'api_url' => 'https://events.pagerduty.com/v2/enqueue',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Add to `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Telegram Bot
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
|
|
||||||
|
# Twilio SMS
|
||||||
|
TWILIO_SID=your_account_sid
|
||||||
|
TWILIO_TOKEN=your_auth_token
|
||||||
|
TWILIO_FROM=+1234567890
|
||||||
|
|
||||||
|
# Opsgenie
|
||||||
|
OPSGENIE_API_KEY=your_api_key
|
||||||
|
|
||||||
|
# Global defaults (optional)
|
||||||
|
DEFAULT_SLACK_CHANNEL=#monitoring
|
||||||
|
DEFAULT_NOTIFICATION_QUEUE=notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notification Settings Controller
|
||||||
|
|
||||||
|
The controller and views for managing notification settings will be created separately to allow users to:
|
||||||
|
- Configure which channels to use for each alert type
|
||||||
|
- Enter webhook URLs and API keys
|
||||||
|
- Test notification delivery
|
||||||
|
- View notification history
|
||||||
|
|
||||||
|
## Channel Setup Guides
|
||||||
|
|
||||||
|
### Slack
|
||||||
|
1. Create an Incoming Webhook in your Slack workspace
|
||||||
|
2. Copy the webhook URL
|
||||||
|
3. Paste in notification settings
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
1. Create a bot via @BotFather
|
||||||
|
2. Get your bot token
|
||||||
|
3. Start a chat with your bot
|
||||||
|
4. Get your chat ID (use @userinfobot)
|
||||||
|
5. Enter chat ID in settings
|
||||||
|
|
||||||
|
### Discord
|
||||||
|
1. Edit channel settings
|
||||||
|
2. Go to Integrations > Webhooks
|
||||||
|
3. Create webhook
|
||||||
|
4. Copy webhook URL
|
||||||
|
|
||||||
|
### Microsoft Teams
|
||||||
|
1. Add "Incoming Webhook" connector to channel
|
||||||
|
2. Configure and create
|
||||||
|
3. Copy webhook URL
|
||||||
|
|
||||||
|
### Google Chat
|
||||||
|
1. Create a webhook in Google Chat space
|
||||||
|
2. Copy webhook URL
|
||||||
|
|
||||||
|
## Testing Notifications
|
||||||
|
|
||||||
|
Create a test command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan make:command TestNotifications
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Notifications\UptimeAlert;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class TestNotifications extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'notifications:test {user}';
|
||||||
|
protected $description = 'Test notification delivery for a user';
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($this->argument('user'));
|
||||||
|
|
||||||
|
$user->notify(new UptimeAlert(
|
||||||
|
'Test Site',
|
||||||
|
false, // Not down
|
||||||
|
200,
|
||||||
|
'https://example.com',
|
||||||
|
150.5
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->info('Test notification sent!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Queue Notifications**: All notifications implement `ShouldQueue`
|
||||||
|
2. **Rate Limiting**: Implement rate limiting to prevent notification spam
|
||||||
|
3. **Retry Logic**: Configure queue retry attempts
|
||||||
|
4. **Monitoring**: Log notification failures
|
||||||
|
5. **Testing**: Test each channel before production use
|
||||||
|
6. **Security**: Encrypt sensitive webhook URLs and API keys
|
||||||
|
7. **User Preferences**: Respect user notification preferences
|
||||||
|
8. **Batching**: Consider batching similar alerts
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Run migration: `php artisan migrate`
|
||||||
|
2. Create notification settings UI
|
||||||
|
3. Integrate with existing monitoring jobs
|
||||||
|
4. Test each notification channel
|
||||||
|
5. Configure queue worker for notifications
|
||||||
|
6. Set up monitoring for the notification system itself
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Notifications not sending
|
||||||
|
- Check queue worker is running
|
||||||
|
- Verify webhook URLs are correct
|
||||||
|
- Check logs: `storage/logs/laravel.log`
|
||||||
|
- Test with `notifications:test` command
|
||||||
|
|
||||||
|
### Channel-specific issues
|
||||||
|
- **Slack**: Verify webhook URL format
|
||||||
|
- **Telegram**: Ensure bot token is valid and chat ID is correct
|
||||||
|
- **Discord**: Check webhook hasn't been deleted
|
||||||
|
- **Email**: Verify mail configuration
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Notification grouping (digest emails)
|
||||||
|
- Quiet hours configuration
|
||||||
|
- Escalation policies
|
||||||
|
- Notification templates
|
||||||
|
- Webhook retry logic
|
||||||
|
- Notification analytics
|
||||||
|
- Mobile push notifications
|
||||||
|
- In-app notification center
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
# Multi-Channel Notifications - Quick Start Guide
|
||||||
|
|
||||||
|
## What's Been Completed
|
||||||
|
|
||||||
|
### ✅ Database Migration
|
||||||
|
- Migration created: `database/migrations/2026_01_18_123539_add_notification_channels_to_users_table.php`
|
||||||
|
- Adds fields for all notification channels to users table
|
||||||
|
- **Run with:** `./vendor/bin/sail artisan migrate`
|
||||||
|
|
||||||
|
### ✅ Packages Installed
|
||||||
|
```json
|
||||||
|
"laravel/slack-notification-channel": "^3.7",
|
||||||
|
"laravel-notification-channels/telegram": "^6.0",
|
||||||
|
"laravel-notification-channels/discord": "^1.7"
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ User Model Updated
|
||||||
|
- Added fillable fields for all notification channels
|
||||||
|
- Added `notification_channels` JSON cast
|
||||||
|
- Added helper methods:
|
||||||
|
- `getNotificationChannelsFor($alertType)` - Get enabled channels for an alert type
|
||||||
|
- `routeNotificationForSlack()` - Slack webhook routing
|
||||||
|
- `routeNotificationForTelegram()` - Telegram chat ID routing
|
||||||
|
- `routeNotificationForDiscord()` - Discord webhook routing
|
||||||
|
|
||||||
|
## Quick Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Run Migration
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create Notification Classes
|
||||||
|
|
||||||
|
All notification class templates are in `MULTI_CHANNEL_NOTIFICATIONS_GUIDE.md`. Copy them to:
|
||||||
|
- `app/Notifications/MonitorAlert.php` (base class)
|
||||||
|
- `app/Notifications/UptimeAlert.php`
|
||||||
|
- `app/Notifications/SslExpiryAlert.php`
|
||||||
|
- `app/Notifications/DomainExpiryAlert.php`
|
||||||
|
- `app/Notifications/SitemapAlert.php`
|
||||||
|
- `app/Notifications/PerformanceAlert.php`
|
||||||
|
|
||||||
|
### Step 3: Create Notification Settings Controller
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan make:controller NotificationSettingsController
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Update Existing Jobs
|
||||||
|
|
||||||
|
Example - Update `CheckSitemap` job:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Replace the sendAlert method in app/Jobs/CheckSitemap.php
|
||||||
|
use App\Notifications\SitemapAlert;
|
||||||
|
|
||||||
|
protected function sendAlert(string $message): void
|
||||||
|
{
|
||||||
|
// Send via new notification system
|
||||||
|
$this->sitemap->site->user->notify(
|
||||||
|
new SitemapAlert(
|
||||||
|
$this->sitemap->site->name,
|
||||||
|
$this->sitemap->url,
|
||||||
|
$this->sitemap->last_error
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Add Environment Variables
|
||||||
|
|
||||||
|
Add to `.env`:
|
||||||
|
```env
|
||||||
|
# Telegram Bot (optional)
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token
|
||||||
|
|
||||||
|
# Twilio SMS (optional)
|
||||||
|
TWILIO_SID=your_sid
|
||||||
|
TWILIO_TOKEN=your_token
|
||||||
|
TWILIO_FROM=+1234567890
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Notification Channels Structure
|
||||||
|
|
||||||
|
The `notification_channels` JSON field stores preferences like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uptime": ["email", "slack"],
|
||||||
|
"ssl": ["email", "telegram", "slack"],
|
||||||
|
"domain_expiry": ["email"],
|
||||||
|
"sitemap": ["database"],
|
||||||
|
"performance": ["slack"],
|
||||||
|
"dns": ["email", "database"],
|
||||||
|
"broken_links": ["database"],
|
||||||
|
"health_check": ["email", "slack"],
|
||||||
|
"tcp_port": ["database"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Channels
|
||||||
|
|
||||||
|
### Built-in (No Additional Setup)
|
||||||
|
- `mail` - Email notifications
|
||||||
|
- `database` - Store in notifications table
|
||||||
|
|
||||||
|
### Webhook-based (Requires URL)
|
||||||
|
- `slack` - Requires `slack_webhook_url`
|
||||||
|
- `discord` - Requires `discord_webhook_url`
|
||||||
|
- `telegram` - Requires `telegram_chat_id` and bot token
|
||||||
|
- `teams` - Requires `teams_webhook_url`
|
||||||
|
- `google_chat` - Requires `google_chat_webhook_url`
|
||||||
|
- `webhook` - Requires `webhook_url` (custom endpoint)
|
||||||
|
|
||||||
|
### API-based (Requires Credentials)
|
||||||
|
- `sms` - Requires Twilio/Vonage setup
|
||||||
|
- `opsgenie` - Requires `opsgenie_api_key`
|
||||||
|
- `pagerduty` - Requires `pagerduty_integration_key`
|
||||||
|
|
||||||
|
## Testing Notifications
|
||||||
|
|
||||||
|
Create a test route to verify notifications work:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In routes/web.php
|
||||||
|
Route::get('/test-notification', function () {
|
||||||
|
auth()->user()->notify(
|
||||||
|
new \App\Notifications\UptimeAlert(
|
||||||
|
'Test Site',
|
||||||
|
false, // not down
|
||||||
|
200,
|
||||||
|
'https://example.com',
|
||||||
|
150.5
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return 'Notification sent! Check your configured channels.';
|
||||||
|
})->middleware('auth');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setting Up Notification Channels
|
||||||
|
|
||||||
|
### Slack
|
||||||
|
1. Go to https://api.slack.com/messaging/webhooks
|
||||||
|
2. Create an Incoming Webhook for your workspace
|
||||||
|
3. Copy the webhook URL
|
||||||
|
4. Save in user settings: `slack_webhook_url`
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
1. Talk to @BotFather on Telegram
|
||||||
|
2. Create a new bot with `/newbot`
|
||||||
|
3. Save the bot token to `.env` as `TELEGRAM_BOT_TOKEN`
|
||||||
|
4. Start a chat with your bot
|
||||||
|
5. Get your chat ID from @userinfobot
|
||||||
|
6. Save in user settings: `telegram_chat_id`
|
||||||
|
|
||||||
|
### Discord
|
||||||
|
1. Go to Server Settings → Integrations → Webhooks
|
||||||
|
2. Create New Webhook
|
||||||
|
3. Copy the Webhook URL
|
||||||
|
4. Save in user settings: `discord_webhook_url`
|
||||||
|
|
||||||
|
### Microsoft Teams
|
||||||
|
1. In Teams, go to channel → Connectors
|
||||||
|
2. Configure "Incoming Webhook"
|
||||||
|
3. Copy the webhook URL
|
||||||
|
4. Save in user settings: `teams_webhook_url`
|
||||||
|
|
||||||
|
## Notification Settings UI (To Be Created)
|
||||||
|
|
||||||
|
Create a settings page where users can:
|
||||||
|
1. Enable/disable channels per alert type
|
||||||
|
2. Configure webhook URLs and API keys
|
||||||
|
3. Test notification delivery
|
||||||
|
4. Set quiet hours
|
||||||
|
5. Configure notification preferences
|
||||||
|
|
||||||
|
## Integration with Existing Monitoring
|
||||||
|
|
||||||
|
Update these jobs to send notifications:
|
||||||
|
- `app/Jobs/CheckSitePing.php` → Use `UptimeAlert`
|
||||||
|
- `app/Jobs/CheckSslCertificate.php` → Use `SslExpiryAlert`
|
||||||
|
- `app/Jobs/CheckDomainExpiry.php` → Use `DomainExpiryAlert`
|
||||||
|
- `app/Jobs/CheckSitemap.php` → Use `SitemapAlert`
|
||||||
|
- `app/Jobs/CheckHealth.php` → Use `PerformanceAlert`
|
||||||
|
|
||||||
|
## Queue Configuration
|
||||||
|
|
||||||
|
Notifications are queued by default. Ensure queue worker is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan queue:work
|
||||||
|
```
|
||||||
|
|
||||||
|
Or configure supervisor in production.
|
||||||
|
|
||||||
|
## Notification Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Monitor Job Detects Issue
|
||||||
|
↓
|
||||||
|
Creates Notification Object
|
||||||
|
↓
|
||||||
|
User::notify(new Alert(...))
|
||||||
|
↓
|
||||||
|
Laravel checks via()
|
||||||
|
↓
|
||||||
|
Gets enabled channels from user preferences
|
||||||
|
↓
|
||||||
|
Dispatches to each channel
|
||||||
|
↓
|
||||||
|
Queues notification jobs
|
||||||
|
↓
|
||||||
|
Worker processes notifications
|
||||||
|
↓
|
||||||
|
Sends to: Email, Slack, Telegram, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Encrypt sensitive fields** - Consider encrypting webhook URLs and API keys
|
||||||
|
2. **Rate limiting** - Prevent notification spam
|
||||||
|
3. **Validate webhooks** - Verify webhook URLs before saving
|
||||||
|
4. **Sanitize data** - Clean user input in notifications
|
||||||
|
5. **HTTPS only** - Only allow HTTPS webhook URLs
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Notifications not sending
|
||||||
|
1. Check queue worker is running: `ps aux | grep queue`
|
||||||
|
2. Check failed jobs: `./vendor/bin/sail artisan queue:failed`
|
||||||
|
3. Review logs: `storage/logs/laravel.log`
|
||||||
|
4. Verify user has configured channels
|
||||||
|
5. Test with simple email notification first
|
||||||
|
|
||||||
|
### Channel-specific issues
|
||||||
|
|
||||||
|
**Slack:** "Invalid webhook URL"
|
||||||
|
- Verify URL format: https://hooks.slack.com/services/...
|
||||||
|
- Check workspace hasn't revoked webhook
|
||||||
|
|
||||||
|
**Telegram:** "Bad Request: chat not found"
|
||||||
|
- Verify chat ID is correct
|
||||||
|
- Ensure bot token is valid
|
||||||
|
- Start conversation with bot first
|
||||||
|
|
||||||
|
**Discord:** "Webhook not found"
|
||||||
|
- Check webhook wasn't deleted
|
||||||
|
- Verify URL hasn't expired
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. **Use queues** - Always queue notifications
|
||||||
|
2. **Batch similar alerts** - Group notifications when possible
|
||||||
|
3. **Debounce** - Prevent rapid repeated notifications
|
||||||
|
4. **Cache user preferences** - Cache notification_channels
|
||||||
|
5. **Async webhooks** - Don't wait for webhook responses
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Create UI** - Build notification settings interface
|
||||||
|
2. **Add more channels** - Implement Teams, Google Chat, etc.
|
||||||
|
3. **Test thoroughly** - Test each channel
|
||||||
|
4. **Update docs** - Document for your team
|
||||||
|
5. **Monitor** - Track notification success rates
|
||||||
|
|
||||||
|
## Example: Full Integration
|
||||||
|
|
||||||
|
Here's how to fully integrate with an existing job:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Site;
|
||||||
|
use App\Notifications\UptimeAlert;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class CheckSiteUptime implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Site $site
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$response = Http::timeout(10)->get($this->site->url);
|
||||||
|
|
||||||
|
$isDown = !$response->successful();
|
||||||
|
$statusCode = $response->status();
|
||||||
|
$responseTime = $response->transferStats->getTransferTime() * 1000;
|
||||||
|
|
||||||
|
// Check if status changed
|
||||||
|
$previousStatus = $this->site->latestUptimeCheck->is_online ?? true;
|
||||||
|
|
||||||
|
// Send notification if status changed
|
||||||
|
if ($isDown !== !$previousStatus) {
|
||||||
|
$this->site->user->notify(
|
||||||
|
new UptimeAlert(
|
||||||
|
$this->site->name,
|
||||||
|
$isDown,
|
||||||
|
$statusCode,
|
||||||
|
$this->site->url,
|
||||||
|
$responseTime
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save check result...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- Laravel Notifications: https://laravel.com/docs/notifications
|
||||||
|
- Slack API: https://api.slack.com/
|
||||||
|
- Telegram Bot API: https://core.telegram.org/bots/api
|
||||||
|
- Discord Webhooks: https://discord.com/developers/docs/resources/webhook
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# Sitemap Monitoring Setup
|
||||||
|
|
||||||
|
This feature allows you to monitor your website sitemaps to ensure they are accessible, valid XML, and properly formatted.
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### 1. Database Migration
|
||||||
|
- **Table:** `sitemaps`
|
||||||
|
- **Columns:**
|
||||||
|
- `id` - Primary key
|
||||||
|
- `site_id` - Foreign key to sites table
|
||||||
|
- `url` - Full URL to the sitemap file
|
||||||
|
- `last_checked_at` - Last validation timestamp
|
||||||
|
- `is_valid` - Boolean indicating if sitemap is valid
|
||||||
|
- `last_error` - Text field storing validation errors
|
||||||
|
- `is_active` - Enable/disable monitoring
|
||||||
|
- `url_count` - Number of URLs found in sitemap
|
||||||
|
- `created_at`, `updated_at` - Timestamps
|
||||||
|
|
||||||
|
### 2. Models & Relationships
|
||||||
|
- **Sitemap Model** (`app/Models/Sitemap.php`)
|
||||||
|
- Belongs to Site
|
||||||
|
- Helper methods:
|
||||||
|
- `hasErrors()` - Check if sitemap has validation issues
|
||||||
|
- `getStatusBadgeClass()` - Get Bootstrap badge color for status
|
||||||
|
- `getStatusText()` - Get human-readable status text
|
||||||
|
- `markAsValid()` - Mark sitemap as valid with URL count
|
||||||
|
- `markAsInvalid()` - Mark sitemap as invalid with error message
|
||||||
|
|
||||||
|
- **Site Model** updated with `sitemaps()` relationship
|
||||||
|
|
||||||
|
### 3. Background Job
|
||||||
|
- **Job:** `CheckSitemap` (`app/Jobs/CheckSitemap.php`)
|
||||||
|
- Fetches sitemap URL with proper user agent
|
||||||
|
- Validates XML structure using `simplexml_load_string()`
|
||||||
|
- Checks for valid sitemap format (urlset or sitemapindex)
|
||||||
|
- Counts URLs in the sitemap
|
||||||
|
- Creates notifications for invalid sitemaps
|
||||||
|
- Comprehensive error handling and logging
|
||||||
|
|
||||||
|
### 4. XML Validation
|
||||||
|
Uses PHP's built-in `simplexml_load_string()` with error handling:
|
||||||
|
- Validates well-formed XML syntax
|
||||||
|
- Checks for proper sitemap structure
|
||||||
|
- Supports both regular sitemaps and sitemap indexes
|
||||||
|
- Handles XML namespaces correctly
|
||||||
|
- Provides detailed error messages
|
||||||
|
|
||||||
|
### 5. Artisan Command
|
||||||
|
- **Command:** `monitor:sitemaps`
|
||||||
|
- Dispatches `CheckSitemap` job for all active sitemaps
|
||||||
|
- Shows progress bar
|
||||||
|
- Run manually: `./vendor/bin/sail artisan monitor:sitemaps`
|
||||||
|
|
||||||
|
### 6. Scheduled Task
|
||||||
|
- **Schedule:** Daily at midnight (configurable in `routes/console.php:68`)
|
||||||
|
- Automatically runs `monitor:sitemaps` command
|
||||||
|
- Logs output to `storage/logs/sitemap-monitoring.log`
|
||||||
|
|
||||||
|
### 7. Web Interface
|
||||||
|
|
||||||
|
#### Routes
|
||||||
|
- `GET /sitemaps` - List all sitemaps
|
||||||
|
- `GET /sitemaps/create` - Add new sitemap form
|
||||||
|
- `POST /sitemaps` - Store new sitemap
|
||||||
|
- `GET /sitemaps/{sitemap}` - View sitemap details
|
||||||
|
- `GET /sitemaps/{sitemap}/edit` - Edit sitemap form
|
||||||
|
- `PUT /sitemaps/{sitemap}` - Update sitemap
|
||||||
|
- `DELETE /sitemaps/{sitemap}` - Delete sitemap
|
||||||
|
- `POST /sitemaps/{sitemap}/check` - Manually trigger validation
|
||||||
|
|
||||||
|
#### Views
|
||||||
|
- `resources/views/sitemaps/index.blade.php` - Sitemap list with status badges
|
||||||
|
- `resources/views/sitemaps/create.blade.php` - Add new sitemap
|
||||||
|
- `resources/views/sitemaps/edit.blade.php` - Edit sitemap
|
||||||
|
- `resources/views/sitemaps/show.blade.php` - Sitemap details with validation info
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Run the Migration
|
||||||
|
|
||||||
|
If not already run:
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start the Queue Worker
|
||||||
|
|
||||||
|
The sitemap validation jobs run in the background queue:
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan queue:work
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if you're already running the queue worker, it will automatically pick up these jobs.
|
||||||
|
|
||||||
|
### 3. Set Up Scheduled Tasks
|
||||||
|
|
||||||
|
Make sure your Laravel scheduler is running. The scheduler should already be configured if you followed the domain monitoring setup.
|
||||||
|
|
||||||
|
In production, ensure this is in your crontab:
|
||||||
|
```
|
||||||
|
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
In development with Sail, the scheduler runs automatically if started with `composer dev`.
|
||||||
|
|
||||||
|
### 4. Test the System
|
||||||
|
|
||||||
|
1. **Add a sitemap:**
|
||||||
|
- Navigate to `/sitemaps`
|
||||||
|
- Click "Add Sitemap"
|
||||||
|
- Select a site and enter your sitemap URL
|
||||||
|
- Save
|
||||||
|
|
||||||
|
2. **Manual validation:**
|
||||||
|
- View the sitemap details
|
||||||
|
- Click "Validate Now"
|
||||||
|
- The job will queue and process in the background
|
||||||
|
|
||||||
|
3. **Test the command:**
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail artisan monitor:sitemaps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Adding Sitemaps
|
||||||
|
1. Go to "Sitemap Monitoring" in your application
|
||||||
|
2. Click "Add Sitemap"
|
||||||
|
3. Select the associated site
|
||||||
|
4. Enter the full sitemap URL (e.g., https://example.com/sitemap.xml)
|
||||||
|
5. Save - the system will automatically validate it
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
- **Automatic:** Runs daily via scheduled task
|
||||||
|
- **Manual:** Click "Validate Now" on any sitemap
|
||||||
|
- **Command:** Run `./vendor/bin/sail artisan monitor:sitemaps` anytime
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
When a sitemap becomes invalid or inaccessible:
|
||||||
|
- A notification is created in the system
|
||||||
|
- Shows in your alerts/notifications section
|
||||||
|
- Contains sitemap URL and error details
|
||||||
|
|
||||||
|
### Status Badges
|
||||||
|
- **Valid** (Green) - Sitemap is accessible and well-formed
|
||||||
|
- **Invalid** (Red) - Sitemap has errors or is inaccessible
|
||||||
|
- **Not Checked** (Gray) - Validation not yet performed
|
||||||
|
|
||||||
|
## Validation Checks
|
||||||
|
|
||||||
|
The system validates the following:
|
||||||
|
|
||||||
|
### 1. HTTP Accessibility
|
||||||
|
- Sitemap URL returns HTTP 200 status
|
||||||
|
- Content can be fetched successfully
|
||||||
|
- Proper timeout handling (30 seconds)
|
||||||
|
|
||||||
|
### 2. XML Well-Formedness
|
||||||
|
- Valid XML syntax
|
||||||
|
- No malformed tags or attributes
|
||||||
|
- Proper encoding
|
||||||
|
- Detailed XML error messages if invalid
|
||||||
|
|
||||||
|
### 3. Sitemap Structure
|
||||||
|
- Contains `<urlset>` root element (for regular sitemaps)
|
||||||
|
- OR contains `<sitemapindex>` root element (for sitemap indexes)
|
||||||
|
- Proper namespace declarations
|
||||||
|
- Valid sitemap format according to sitemaps.org schema
|
||||||
|
|
||||||
|
### 4. URL Counting
|
||||||
|
- Counts `<url>` entries in regular sitemaps
|
||||||
|
- Counts `<sitemap>` entries in sitemap indexes
|
||||||
|
- Handles namespaced and non-namespaced XML
|
||||||
|
|
||||||
|
## Supported Sitemap Formats
|
||||||
|
|
||||||
|
### Regular Sitemap
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://example.com/page1</loc>
|
||||||
|
<lastmod>2026-01-18</lastmod>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<!-- More URLs -->
|
||||||
|
</urlset>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sitemap Index
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<sitemap>
|
||||||
|
<loc>https://example.com/sitemap1.xml</loc>
|
||||||
|
<lastmod>2026-01-18</lastmod>
|
||||||
|
</sitemap>
|
||||||
|
<!-- More sitemaps -->
|
||||||
|
</sitemapindex>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Jobs not processing
|
||||||
|
- Check queue worker is running: `ps aux | grep queue`
|
||||||
|
- Check failed jobs: `./vendor/bin/sail artisan queue:failed`
|
||||||
|
- Restart queue: `./vendor/bin/sail artisan queue:restart`
|
||||||
|
|
||||||
|
### Sitemap validation errors
|
||||||
|
- Verify sitemap URL is publicly accessible
|
||||||
|
- Check sitemap is valid XML (use online XML validator)
|
||||||
|
- Ensure proper XML encoding (UTF-8 recommended)
|
||||||
|
- Review logs: `storage/logs/laravel.log`
|
||||||
|
|
||||||
|
### False positive errors
|
||||||
|
- Some servers may block automated requests
|
||||||
|
- Try accessing sitemap URL manually in browser
|
||||||
|
- Check if server requires specific User-Agent headers
|
||||||
|
- Verify no firewall or security rules blocking requests
|
||||||
|
|
||||||
|
### Schedule not running
|
||||||
|
- Development: Ensure scheduler is part of your dev command
|
||||||
|
- Production: Verify cron job is set up
|
||||||
|
- Test manually: `./vendor/bin/sail artisan schedule:run`
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Console/Commands/
|
||||||
|
│ └── MonitorSitemaps.php # Artisan command
|
||||||
|
├── Http/Controllers/
|
||||||
|
│ └── SitemapController.php # Web controller
|
||||||
|
├── Jobs/
|
||||||
|
│ └── CheckSitemap.php # Background job
|
||||||
|
└── Models/
|
||||||
|
├── Sitemap.php # Sitemap model
|
||||||
|
└── Site.php # Updated with sitemaps relationship
|
||||||
|
|
||||||
|
database/migrations/
|
||||||
|
└── 2026_01_18_123118_create_sitemaps_table.php
|
||||||
|
|
||||||
|
resources/views/sitemaps/
|
||||||
|
├── index.blade.php # List view
|
||||||
|
├── create.blade.php # Create form
|
||||||
|
├── edit.blade.php # Edit form
|
||||||
|
└── show.blade.php # Detail view
|
||||||
|
|
||||||
|
routes/
|
||||||
|
├── console.php # Scheduled tasks (line 68)
|
||||||
|
└── web.php # Web routes (lines 32-33)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Sitemap Issues
|
||||||
|
|
||||||
|
### 1. 404 Not Found
|
||||||
|
- Sitemap file doesn't exist at specified URL
|
||||||
|
- Check URL spelling and path
|
||||||
|
- Verify sitemap is published to server
|
||||||
|
|
||||||
|
### 2. XML Parsing Errors
|
||||||
|
- Malformed XML syntax
|
||||||
|
- Missing closing tags
|
||||||
|
- Invalid characters in URLs
|
||||||
|
- Use XML validator to identify specific issues
|
||||||
|
|
||||||
|
### 3. Incorrect Namespace
|
||||||
|
- Ensure using correct namespace: `http://www.sitemaps.org/schemas/sitemap/0.9`
|
||||||
|
- Namespace must match sitemap protocol specification
|
||||||
|
|
||||||
|
### 4. Access Denied (403)
|
||||||
|
- Server blocking automated requests
|
||||||
|
- May need to whitelist monitoring server IP
|
||||||
|
- Check robots.txt isn't blocking sitemap
|
||||||
|
|
||||||
|
### 5. Timeout Errors
|
||||||
|
- Sitemap file too large (>50MB)
|
||||||
|
- Server response too slow
|
||||||
|
- Consider splitting into sitemap index
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Keep Sitemaps Updated:** Regenerate sitemaps when content changes
|
||||||
|
2. **Optimize Size:** Large sitemaps (>50,000 URLs) should use sitemap index
|
||||||
|
3. **Compress Large Files:** Use gzip compression for large sitemaps
|
||||||
|
4. **Valid XML:** Always validate XML before publishing
|
||||||
|
5. **Monitor Regularly:** Enable active monitoring for all production sitemaps
|
||||||
|
6. **Fix Issues Promptly:** Address validation errors as soon as they appear
|
||||||
|
|
||||||
|
## Integration with Search Engines
|
||||||
|
|
||||||
|
While this tool validates sitemaps, remember to:
|
||||||
|
- Submit sitemaps to Google Search Console
|
||||||
|
- Submit sitemaps to Bing Webmaster Tools
|
||||||
|
- Update robots.txt with sitemap location
|
||||||
|
- Ping search engines after sitemap updates
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Consider adding:
|
||||||
|
- Email notifications for invalid sitemaps
|
||||||
|
- Slack/Discord webhooks for alerts
|
||||||
|
- Detailed XML validation reporting
|
||||||
|
- Automatic sitemap ping to search engines
|
||||||
|
- Historical validation tracking
|
||||||
|
- Sitemap size and compression detection
|
||||||
|
- URL validation within sitemaps
|
||||||
|
- Comparison with previous versions
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\CheckDomainExpiry;
|
||||||
|
use App\Models\Domain;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class MonitorDomains extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'monitor:domains';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Check expiry dates for all active domains';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$this->info('Starting domain expiry monitoring...');
|
||||||
|
|
||||||
|
$domains = Domain::where('is_active', true)->get();
|
||||||
|
|
||||||
|
if ($domains->isEmpty()) {
|
||||||
|
$this->warn('No active domains found to monitor.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$domains->count()} active domain(s) to check.");
|
||||||
|
|
||||||
|
$progressBar = $this->output->createProgressBar($domains->count());
|
||||||
|
$progressBar->start();
|
||||||
|
|
||||||
|
foreach ($domains as $domain) {
|
||||||
|
CheckDomainExpiry::dispatch($domain);
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->info('Domain expiry check jobs have been dispatched.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\CheckSitemap;
|
||||||
|
use App\Models\Sitemap;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class MonitorSitemaps extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'monitor:sitemaps';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Check all active sitemaps for validity and accessibility';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$this->info('Starting sitemap monitoring...');
|
||||||
|
|
||||||
|
$sitemaps = Sitemap::where('is_active', true)->get();
|
||||||
|
|
||||||
|
if ($sitemaps->isEmpty()) {
|
||||||
|
$this->warn('No active sitemaps found to monitor.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$sitemaps->count()} active sitemap(s) to check.");
|
||||||
|
|
||||||
|
$progressBar = $this->output->createProgressBar($sitemaps->count());
|
||||||
|
$progressBar->start();
|
||||||
|
|
||||||
|
foreach ($sitemaps as $sitemap) {
|
||||||
|
CheckSitemap::dispatch($sitemap);
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->info('Sitemap check jobs have been dispatched.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Site;
|
||||||
|
use App\Models\BrokenLink;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class BrokenLinkController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display broken links overview for all sites
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', auth()->id())
|
||||||
|
->withCount('brokenLinks')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get broken link statistics
|
||||||
|
$stats = [
|
||||||
|
'total_sites' => $sites->count(),
|
||||||
|
'sites_with_broken_links' => $sites->where('broken_links_count', '>', 0)->count(),
|
||||||
|
'total_broken_links' => $sites->sum('broken_links_count'),
|
||||||
|
'sites_clean' => $sites->where('broken_links_count', 0)->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get recent broken links across all sites
|
||||||
|
$recentBrokenLinks = BrokenLink::whereHas('site', function ($query) {
|
||||||
|
$query->where('user_id', auth()->id());
|
||||||
|
})
|
||||||
|
->with('site')
|
||||||
|
->latest()
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('broken-links.index', compact('sites', 'stats', 'recentBrokenLinks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display broken links for a specific site
|
||||||
|
*/
|
||||||
|
public function show(Site $site)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $site);
|
||||||
|
|
||||||
|
$brokenLinks = $site->brokenLinks()
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(50);
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_broken_links' => $brokenLinks->total(),
|
||||||
|
'unique_urls' => $site->brokenLinks()->distinct('url')->count('url'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('broken-links.show', compact('site', 'brokenLinks', 'stats'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,27 @@ use Illuminate\View\View;
|
|||||||
|
|
||||||
class DnsRecordController extends Controller
|
class DnsRecordController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Display overview of all DNS records across all sites
|
||||||
|
*/
|
||||||
|
public function overview(): View
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', Auth::id())
|
||||||
|
->withCount('dnsRecords')
|
||||||
|
->with(['dnsRecords' => function ($query) {
|
||||||
|
$query->where('is_active', true)->limit(5);
|
||||||
|
}])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_sites' => $sites->count(),
|
||||||
|
'total_dns_records' => $sites->sum('dns_records_count'),
|
||||||
|
'sites_with_records' => $sites->where('dns_records_count', '>', 0)->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('dns-records.overview', compact('sites', 'stats'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a listing of the DNS records for a site.
|
* Display a listing of the DNS records for a site.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Jobs\CheckDomainExpiry;
|
||||||
|
use App\Models\Domain;
|
||||||
|
use App\Models\Site;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DomainController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$domains = Domain::with('site')
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('expires_at', 'asc')
|
||||||
|
->paginate(15);
|
||||||
|
|
||||||
|
return view('domains.index', compact('domains'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new resource.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', auth()->id())
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('domains.create', compact('sites'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'site_id' => 'required|exists:sites,id',
|
||||||
|
'domain' => 'required|string|max:255',
|
||||||
|
'is_active' => 'sometimes|nullable|in:on,off',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $request->has('is_active');
|
||||||
|
|
||||||
|
$domain = Domain::create($validated);
|
||||||
|
|
||||||
|
// Dispatch job to check domain immediately
|
||||||
|
CheckDomainExpiry::dispatch($domain);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('domains.index')
|
||||||
|
->with('success', 'Domain added successfully. Checking expiry date...');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified resource.
|
||||||
|
*/
|
||||||
|
public function show(Domain $domain)
|
||||||
|
{
|
||||||
|
$domain->load('site');
|
||||||
|
|
||||||
|
return view('domains.show', compact('domain'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified resource.
|
||||||
|
*/
|
||||||
|
public function edit(Domain $domain)
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', auth()->id())
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('domains.edit', compact('domain', 'sites'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Domain $domain)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'site_id' => 'required|exists:sites,id',
|
||||||
|
'domain' => 'required|string|max:255',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $request->has('is_active');
|
||||||
|
|
||||||
|
$domain->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('domains.index')
|
||||||
|
->with('success', 'Domain updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Domain $domain)
|
||||||
|
{
|
||||||
|
$domain->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('domains.index')
|
||||||
|
->with('success', 'Domain deleted successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger domain expiry check
|
||||||
|
*/
|
||||||
|
public function check(Domain $domain)
|
||||||
|
{
|
||||||
|
CheckDomainExpiry::dispatch($domain);
|
||||||
|
|
||||||
|
return back()
|
||||||
|
->with('success', 'Domain expiry check has been queued.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,27 @@ use Illuminate\View\View;
|
|||||||
|
|
||||||
class HealthCheckController extends Controller
|
class HealthCheckController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Display overview of all health checks across all sites
|
||||||
|
*/
|
||||||
|
public function overview(): View
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', Auth::id())
|
||||||
|
->withCount('healthChecks')
|
||||||
|
->with(['healthChecks' => function ($query) {
|
||||||
|
$query->where('is_active', true)->limit(5);
|
||||||
|
}])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_sites' => $sites->count(),
|
||||||
|
'total_health_checks' => $sites->sum('health_checks_count'),
|
||||||
|
'sites_with_checks' => $sites->where('health_checks_count', '>', 0)->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('health-checks.overview', compact('sites', 'stats'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a listing of the health checks for a site.
|
* Display a listing of the health checks for a site.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Jobs\CheckSitemap;
|
||||||
|
use App\Models\Sitemap;
|
||||||
|
use App\Models\Site;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class SitemapController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$sitemaps = Sitemap::with('site')
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('is_valid', 'asc')
|
||||||
|
->orderBy('last_checked_at', 'desc')
|
||||||
|
->paginate(15);
|
||||||
|
|
||||||
|
return view('sitemaps.index', compact('sitemaps'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new resource.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', auth()->id())
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('sitemaps.create', compact('sites'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'site_id' => 'required|exists:sites,id',
|
||||||
|
'url' => 'required|url|max:500',
|
||||||
|
'is_active' => 'sometimes|nullable|in:on,off',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $request->has('is_active');
|
||||||
|
|
||||||
|
$sitemap = Sitemap::create($validated);
|
||||||
|
|
||||||
|
// Dispatch job to check sitemap immediately
|
||||||
|
CheckSitemap::dispatch($sitemap);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('sitemaps.index')
|
||||||
|
->with('success', 'Sitemap added successfully. Validating now...');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified resource.
|
||||||
|
*/
|
||||||
|
public function show(Sitemap $sitemap)
|
||||||
|
{
|
||||||
|
$sitemap->load('site');
|
||||||
|
|
||||||
|
return view('sitemaps.show', compact('sitemap'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified resource.
|
||||||
|
*/
|
||||||
|
public function edit(Sitemap $sitemap)
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', auth()->id())
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('sitemaps.edit', compact('sitemap', 'sites'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Sitemap $sitemap)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'site_id' => 'required|exists:sites,id',
|
||||||
|
'url' => 'required|url|max:500',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['is_active'] = $request->has('is_active');
|
||||||
|
|
||||||
|
$sitemap->update($validated);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('sitemaps.index')
|
||||||
|
->with('success', 'Sitemap updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Sitemap $sitemap)
|
||||||
|
{
|
||||||
|
$sitemap->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('sitemaps.index')
|
||||||
|
->with('success', 'Sitemap deleted successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger sitemap validation
|
||||||
|
*/
|
||||||
|
public function check(Sitemap $sitemap)
|
||||||
|
{
|
||||||
|
CheckSitemap::dispatch($sitemap);
|
||||||
|
|
||||||
|
return back()
|
||||||
|
->with('success', 'Sitemap validation has been queued.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Site;
|
||||||
|
use App\Models\SslCertificate;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class SslCertificateController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display SSL certificate overview for all sites
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', auth()->id())
|
||||||
|
->with(['sslCertificates' => function ($query) {
|
||||||
|
$query->latest()->limit(1);
|
||||||
|
}])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get SSL statistics
|
||||||
|
$stats = [
|
||||||
|
'total_sites' => $sites->count(),
|
||||||
|
'valid_certs' => 0,
|
||||||
|
'expiring_soon' => 0,
|
||||||
|
'expired' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sites as $site) {
|
||||||
|
$latestCert = $site->sslCertificates->first();
|
||||||
|
if ($latestCert) {
|
||||||
|
if ($latestCert->expires_at) {
|
||||||
|
$daysUntilExpiry = now()->diffInDays($latestCert->expires_at, false);
|
||||||
|
|
||||||
|
if ($daysUntilExpiry < 0) {
|
||||||
|
$stats['expired']++;
|
||||||
|
} elseif ($daysUntilExpiry <= 30) {
|
||||||
|
$stats['expiring_soon']++;
|
||||||
|
} else {
|
||||||
|
$stats['valid_certs']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent SSL checks across all sites
|
||||||
|
$recentCertificates = SslCertificate::whereHas('site', function ($query) {
|
||||||
|
$query->where('user_id', auth()->id());
|
||||||
|
})
|
||||||
|
->with('site')
|
||||||
|
->latest()
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('ssl.index', compact('sites', 'stats', 'recentCertificates'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display SSL certificate details for a specific site
|
||||||
|
*/
|
||||||
|
public function show(Site $site)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $site);
|
||||||
|
|
||||||
|
$sslCertificates = $site->sslCertificates()
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(50);
|
||||||
|
|
||||||
|
$latestCert = $site->sslCertificates()->latest()->first();
|
||||||
|
|
||||||
|
return view('ssl.show', compact('site', 'sslCertificates', 'latestCert'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,27 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
|
|
||||||
class TcpPortController extends Controller
|
class TcpPortController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Display overview of all TCP ports across all sites
|
||||||
|
*/
|
||||||
|
public function overview()
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', Auth::id())
|
||||||
|
->withCount('tcpPorts')
|
||||||
|
->with(['tcpPorts' => function ($query) {
|
||||||
|
$query->where('is_active', true)->limit(5);
|
||||||
|
}])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_sites' => $sites->count(),
|
||||||
|
'total_ports_monitored' => $sites->sum('tcp_ports_count'),
|
||||||
|
'sites_with_ports' => $sites->where('tcp_ports_count', '>', 0)->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('tcp-ports.overview', compact('sites', 'stats'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* Display a listing of the resource.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Site;
|
||||||
|
use App\Models\UptimeCheck;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UptimeController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display uptime monitoring overview for all sites
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$sites = Site::where('user_id', auth()->id())
|
||||||
|
->with(['uptimeChecks' => function ($query) {
|
||||||
|
$query->latest()->limit(1);
|
||||||
|
}])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get uptime statistics
|
||||||
|
$stats = [
|
||||||
|
'total_sites' => $sites->count(),
|
||||||
|
'sites_up' => 0,
|
||||||
|
'sites_down' => 0,
|
||||||
|
'avg_response_time' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$totalResponseTime = 0;
|
||||||
|
$checksCount = 0;
|
||||||
|
|
||||||
|
foreach ($sites as $site) {
|
||||||
|
$latestCheck = $site->uptimeChecks->first();
|
||||||
|
if ($latestCheck) {
|
||||||
|
if ($latestCheck->is_online) {
|
||||||
|
$stats['sites_up']++;
|
||||||
|
} else {
|
||||||
|
$stats['sites_down']++;
|
||||||
|
}
|
||||||
|
$totalResponseTime += $latestCheck->response_time ?? 0;
|
||||||
|
$checksCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($checksCount > 0) {
|
||||||
|
$stats['avg_response_time'] = round($totalResponseTime / $checksCount, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent checks across all sites
|
||||||
|
$recentChecks = UptimeCheck::whereHas('site', function ($query) {
|
||||||
|
$query->where('user_id', auth()->id());
|
||||||
|
})
|
||||||
|
->with('site')
|
||||||
|
->latest()
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('uptime.index', compact('sites', 'stats', 'recentChecks'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display uptime details for a specific site
|
||||||
|
*/
|
||||||
|
public function show(Site $site)
|
||||||
|
{
|
||||||
|
$this->authorize('view', $site);
|
||||||
|
|
||||||
|
$uptimeChecks = $site->uptimeChecks()
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->paginate(100);
|
||||||
|
|
||||||
|
// Calculate uptime percentage for last 30 days
|
||||||
|
$checksLast30Days = $site->uptimeChecks()
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$uptimePercentage = 0;
|
||||||
|
if ($checksLast30Days->count() > 0) {
|
||||||
|
$upCount = $checksLast30Days->where('is_online', true)->count();
|
||||||
|
$uptimePercentage = round(($upCount / $checksLast30Days->count()) * 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('uptime.show', compact('site', 'uptimeChecks', 'uptimePercentage'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Domain;
|
||||||
|
use App\Models\Notification;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class CheckDomainExpiry implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public Domain $domain
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Use WHOIS API to check domain expiry
|
||||||
|
// We'll use the WHOIS XML API (https://www.whoisxmlapi.com/)
|
||||||
|
// You can also use other providers like whoisfreaks.com or domainr.com
|
||||||
|
|
||||||
|
$apiKey = config('services.whois.api_key');
|
||||||
|
|
||||||
|
if (!$apiKey) {
|
||||||
|
Log::warning('WHOIS API key not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = Http::timeout(30)
|
||||||
|
->get('https://www.whoisxmlapi.com/whoisserver/WhoisService', [
|
||||||
|
'apiKey' => $apiKey,
|
||||||
|
'domainName' => $this->domain->domain,
|
||||||
|
'outputFormat' => 'JSON',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
// Parse expiry date from WHOIS response
|
||||||
|
$expiryDate = $this->parseExpiryDate($data);
|
||||||
|
|
||||||
|
if ($expiryDate) {
|
||||||
|
$this->domain->update([
|
||||||
|
'expires_at' => $expiryDate,
|
||||||
|
'last_checked_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if domain is expiring soon (within 30 days)
|
||||||
|
if ($this->domain->isExpiringSoon()) {
|
||||||
|
$this->sendExpiryAlert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log::error('WHOIS API request failed', [
|
||||||
|
'domain' => $this->domain->domain,
|
||||||
|
'status' => $response->status(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->domain->update([
|
||||||
|
'last_checked_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error checking domain expiry', [
|
||||||
|
'domain' => $this->domain->domain,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e; // Re-throw to allow job retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse expiry date from WHOIS response
|
||||||
|
*/
|
||||||
|
protected function parseExpiryDate(array $data): ?Carbon
|
||||||
|
{
|
||||||
|
// Try multiple possible fields where expiry date might be
|
||||||
|
$possibleFields = [
|
||||||
|
'WhoisRecord.registryData.expiresDate',
|
||||||
|
'WhoisRecord.expiresDate',
|
||||||
|
'expiresDate',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($possibleFields as $field) {
|
||||||
|
$value = data_get($data, $field);
|
||||||
|
|
||||||
|
if ($value) {
|
||||||
|
try {
|
||||||
|
return Carbon::parse($value);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::warning('Failed to parse date', [
|
||||||
|
'field' => $field,
|
||||||
|
'value' => $value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send alert notification for expiring domain
|
||||||
|
*/
|
||||||
|
protected function sendExpiryAlert(): void
|
||||||
|
{
|
||||||
|
$daysUntilExpiry = $this->domain->daysUntilExpiration();
|
||||||
|
|
||||||
|
$message = $this->domain->isExpired()
|
||||||
|
? "Domain '{$this->domain->domain}' has expired!"
|
||||||
|
: "Domain '{$this->domain->domain}' expires in {$daysUntilExpiry} days (on {$this->domain->expires_at->format('Y-m-d')})";
|
||||||
|
|
||||||
|
Notification::create([
|
||||||
|
'user_id' => $this->domain->site->user_id,
|
||||||
|
'type' => 'domain_expiry',
|
||||||
|
'message' => $message,
|
||||||
|
'data' => json_encode([
|
||||||
|
'domain_id' => $this->domain->id,
|
||||||
|
'site_id' => $this->domain->site_id,
|
||||||
|
'domain' => $this->domain->domain,
|
||||||
|
'expires_at' => $this->domain->expires_at,
|
||||||
|
'days_until_expiry' => $daysUntilExpiry,
|
||||||
|
]),
|
||||||
|
'read_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Notification;
|
||||||
|
use App\Models\Sitemap;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class CheckSitemap implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public Sitemap $sitemap
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Fetch the sitemap URL
|
||||||
|
$response = Http::timeout(30)
|
||||||
|
->withHeaders([
|
||||||
|
'User-Agent' => 'Mozilla/5.0 (compatible; SitemapMonitor/1.0)',
|
||||||
|
])
|
||||||
|
->get($this->sitemap->url);
|
||||||
|
|
||||||
|
if (!$response->successful()) {
|
||||||
|
$this->sitemap->markAsInvalid(
|
||||||
|
"HTTP {$response->status()}: Failed to fetch sitemap"
|
||||||
|
);
|
||||||
|
$this->sendAlert("Sitemap is inaccessible (HTTP {$response->status()})");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the response body
|
||||||
|
$xmlContent = $response->body();
|
||||||
|
|
||||||
|
// Validate XML structure using simplexml_load_string
|
||||||
|
libxml_use_internal_errors(true);
|
||||||
|
$xml = simplexml_load_string($xmlContent);
|
||||||
|
|
||||||
|
if ($xml === false) {
|
||||||
|
$errors = libxml_get_errors();
|
||||||
|
$errorMessages = array_map(fn($error) => trim($error->message), $errors);
|
||||||
|
libxml_clear_errors();
|
||||||
|
|
||||||
|
$this->sitemap->markAsInvalid(
|
||||||
|
'Invalid XML: ' . implode('; ', $errorMessages)
|
||||||
|
);
|
||||||
|
$this->sendAlert('Sitemap contains invalid XML');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a valid sitemap (has urlset or sitemapindex)
|
||||||
|
$namespaces = $xml->getNamespaces(true);
|
||||||
|
$isUrlset = isset($xml->url) || isset($namespaces['']);
|
||||||
|
$isSitemapIndex = isset($xml->sitemap);
|
||||||
|
|
||||||
|
if (!$isUrlset && !$isSitemapIndex) {
|
||||||
|
$this->sitemap->markAsInvalid(
|
||||||
|
'Invalid sitemap format: Missing <urlset> or <sitemapindex> root element'
|
||||||
|
);
|
||||||
|
$this->sendAlert('Sitemap has invalid format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count URLs in the sitemap
|
||||||
|
$urlCount = $this->countUrls($xml);
|
||||||
|
|
||||||
|
// Mark as valid
|
||||||
|
$this->sitemap->markAsValid($urlCount);
|
||||||
|
|
||||||
|
Log::info('Sitemap validated successfully', [
|
||||||
|
'sitemap_id' => $this->sitemap->id,
|
||||||
|
'url' => $this->sitemap->url,
|
||||||
|
'url_count' => $urlCount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error checking sitemap', [
|
||||||
|
'sitemap_id' => $this->sitemap->id,
|
||||||
|
'url' => $this->sitemap->url,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->sitemap->markAsInvalid(
|
||||||
|
'Exception: ' . $e->getMessage()
|
||||||
|
);
|
||||||
|
$this->sendAlert('Sitemap check failed: ' . $e->getMessage());
|
||||||
|
|
||||||
|
throw $e; // Re-throw to allow job retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count URLs in the sitemap
|
||||||
|
*/
|
||||||
|
protected function countUrls(\SimpleXMLElement $xml): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
// Register namespaces
|
||||||
|
$xml->registerXPathNamespace('sm', 'http://www.sitemaps.org/schemas/sitemap/0.9');
|
||||||
|
|
||||||
|
// Check for regular sitemap
|
||||||
|
$urls = $xml->xpath('//sm:url') ?: $xml->xpath('//url');
|
||||||
|
if ($urls !== false) {
|
||||||
|
$count = count($urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a sitemap index, count the sitemaps instead
|
||||||
|
if ($count === 0) {
|
||||||
|
$sitemaps = $xml->xpath('//sm:sitemap') ?: $xml->xpath('//sitemap');
|
||||||
|
if ($sitemaps !== false) {
|
||||||
|
$count = count($sitemaps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send alert notification for invalid sitemap
|
||||||
|
*/
|
||||||
|
protected function sendAlert(string $message): void
|
||||||
|
{
|
||||||
|
Notification::create([
|
||||||
|
'user_id' => $this->sitemap->site->user_id,
|
||||||
|
'type' => 'sitemap_invalid',
|
||||||
|
'message' => "{$message}: {$this->sitemap->url}",
|
||||||
|
'data' => json_encode([
|
||||||
|
'sitemap_id' => $this->sitemap->id,
|
||||||
|
'site_id' => $this->sitemap->site_id,
|
||||||
|
'url' => $this->sitemap->url,
|
||||||
|
'error' => $this->sitemap->last_error,
|
||||||
|
]),
|
||||||
|
'read_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class Domain extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'site_id',
|
||||||
|
'domain',
|
||||||
|
'expires_at',
|
||||||
|
'last_checked_at',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'last_checked_at' => 'datetime',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function site(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Site::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if domain is expiring soon (within 30 days)
|
||||||
|
*/
|
||||||
|
public function isExpiringSoon(): bool
|
||||||
|
{
|
||||||
|
if (!$this->expires_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expires_at->lte(Carbon::now()->addDays(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if domain has expired
|
||||||
|
*/
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
if (!$this->expires_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expires_at->lte(Carbon::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get days until expiration
|
||||||
|
*/
|
||||||
|
public function daysUntilExpiration(): ?int
|
||||||
|
{
|
||||||
|
if (!$this->expires_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Carbon::now()->diffInDays($this->expires_at, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,4 +53,14 @@ class Site extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(DnsRecord::class);
|
return $this->hasMany(DnsRecord::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function domains(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Domain::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sitemaps(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Sitemap::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Sitemap extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'site_id',
|
||||||
|
'url',
|
||||||
|
'last_checked_at',
|
||||||
|
'is_valid',
|
||||||
|
'last_error',
|
||||||
|
'is_active',
|
||||||
|
'url_count',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'last_checked_at' => 'datetime',
|
||||||
|
'is_valid' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'url_count' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function site(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Site::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if sitemap has errors
|
||||||
|
*/
|
||||||
|
public function hasErrors(): bool
|
||||||
|
{
|
||||||
|
return !$this->is_valid || !empty($this->last_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status badge color
|
||||||
|
*/
|
||||||
|
public function getStatusBadgeClass(): string
|
||||||
|
{
|
||||||
|
if (!$this->last_checked_at) {
|
||||||
|
return 'bg-secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->is_valid ? 'bg-success' : 'bg-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status text
|
||||||
|
*/
|
||||||
|
public function getStatusText(): string
|
||||||
|
{
|
||||||
|
if (!$this->last_checked_at) {
|
||||||
|
return 'Not Checked';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->is_valid ? 'Valid' : 'Invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark sitemap as valid
|
||||||
|
*/
|
||||||
|
public function markAsValid(?int $urlCount = null): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'is_valid' => true,
|
||||||
|
'last_error' => null,
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
'url_count' => $urlCount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark sitemap as invalid
|
||||||
|
*/
|
||||||
|
public function markAsInvalid(string $error): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'is_valid' => false,
|
||||||
|
'last_error' => $error,
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,16 @@ class User extends Authenticatable
|
|||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
'notification_preferences',
|
'notification_preferences',
|
||||||
|
'notification_channels',
|
||||||
|
'slack_webhook_url',
|
||||||
|
'telegram_chat_id',
|
||||||
|
'discord_webhook_url',
|
||||||
|
'teams_webhook_url',
|
||||||
|
'google_chat_webhook_url',
|
||||||
|
'webhook_url',
|
||||||
|
'sms_phone_number',
|
||||||
|
'opsgenie_api_key',
|
||||||
|
'pagerduty_integration_key',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,9 +57,43 @@ class User extends Authenticatable
|
|||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'notification_preferences' => 'array',
|
'notification_preferences' => 'array',
|
||||||
|
'notification_channels' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enabled notification channels for a specific alert type
|
||||||
|
*/
|
||||||
|
public function getNotificationChannelsFor(string $alertType): array
|
||||||
|
{
|
||||||
|
$channels = $this->notification_channels ?? [];
|
||||||
|
return $channels[$alertType] ?? ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route notification for Slack
|
||||||
|
*/
|
||||||
|
public function routeNotificationForSlack(): ?string
|
||||||
|
{
|
||||||
|
return $this->slack_webhook_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route notification for Telegram
|
||||||
|
*/
|
||||||
|
public function routeNotificationForTelegram(): ?string
|
||||||
|
{
|
||||||
|
return $this->telegram_chat_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route notification for Discord
|
||||||
|
*/
|
||||||
|
public function routeNotificationForDiscord(): ?string
|
||||||
|
{
|
||||||
|
return $this->discord_webhook_url;
|
||||||
|
}
|
||||||
|
|
||||||
public function sites(): HasMany
|
public function sites(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Site::class);
|
return $this->hasMany(Site::class);
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"guzzlehttp/guzzle": "^7.0",
|
"guzzlehttp/guzzle": "^7.0",
|
||||||
|
"laravel-notification-channels/discord": "^1.7",
|
||||||
|
"laravel-notification-channels/telegram": "^6.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.2",
|
"laravel/sanctum": "^4.2",
|
||||||
|
"laravel/slack-notification-channel": "^3.7",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"laravel/ui": "^4.6",
|
"laravel/ui": "^4.6",
|
||||||
"spatie/crawler": "^8.4"
|
"spatie/crawler": "^8.4"
|
||||||
|
|||||||
Generated
+297
-43
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "88cbc7f24cd10b72bef514dc03a4dfd8",
|
"content-hash": "818f4d39c8e6073483b3546a02db62da",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -1053,17 +1053,157 @@
|
|||||||
"time": "2025-08-22T14:27:06+00:00"
|
"time": "2025-08-22T14:27:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel-notification-channels/discord",
|
||||||
"version": "v12.46.0",
|
"version": "1.7.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel-notification-channels/discord.git",
|
||||||
"reference": "9dcff48d25a632c1fadb713024c952fec489c4ae"
|
"reference": "c17b48ba1ae5f13fcb86f769219b660c7839249c"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/9dcff48d25a632c1fadb713024c952fec489c4ae",
|
"url": "https://api.github.com/repos/laravel-notification-channels/discord/zipball/c17b48ba1ae5f13fcb86f769219b660c7839249c",
|
||||||
"reference": "9dcff48d25a632c1fadb713024c952fec489c4ae",
|
"reference": "c17b48ba1ae5f13fcb86f769219b660c7839249c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"guzzlehttp/guzzle": "^6.3 || ^7.0",
|
||||||
|
"illuminate/console": "^7.0 || ^8.0 || ^9.0 || ^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/notifications": "^7.0 || ^8.0 || ^9.0 || ^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/queue": "^7.0 || ^8.0 || ^9.0 || ^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/support": "^7.0 || ^8.0 || ^9.0 || ^10.0|^11.0|^12.0",
|
||||||
|
"php": "^7.2|^8.0",
|
||||||
|
"textalk/websocket": "^1.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.3.3",
|
||||||
|
"orchestra/testbench": "^6.0 || ^7.0 || ^8.0|^9.0|^10.0",
|
||||||
|
"phpunit/phpunit": "^8.5 || ^9.0|^10.5|^11.5.3"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"NotificationChannels\\Discord\\DiscordServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"NotificationChannels\\Discord\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Cody Scott",
|
||||||
|
"email": "cs475x@icloud.com",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel notification driver for Discord.",
|
||||||
|
"homepage": "https://github.com/laravel-notification-channels/discord",
|
||||||
|
"keywords": [
|
||||||
|
"channel",
|
||||||
|
"discord",
|
||||||
|
"driver",
|
||||||
|
"laravel",
|
||||||
|
"notification"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel-notification-channels/discord/issues",
|
||||||
|
"source": "https://github.com/laravel-notification-channels/discord/tree/1.7.0"
|
||||||
|
},
|
||||||
|
"time": "2025-03-29T08:38:25+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel-notification-channels/telegram",
|
||||||
|
"version": "6.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel-notification-channels/telegram.git",
|
||||||
|
"reference": "289eb825f6c918b21bbda19bffb900d882c9647a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel-notification-channels/telegram/zipball/289eb825f6c918b21bbda19bffb900d882c9647a",
|
||||||
|
"reference": "289eb825f6c918b21bbda19bffb900d882c9647a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
|
"illuminate/contracts": "^11.0 || ^12.0",
|
||||||
|
"illuminate/notifications": "^11.0 || ^12.0",
|
||||||
|
"illuminate/support": "^11.0 || ^12.0",
|
||||||
|
"php": "^8.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"larastan/larastan": "^3.0",
|
||||||
|
"mockery/mockery": "^1.4.4",
|
||||||
|
"orchestra/testbench": "^10.0",
|
||||||
|
"pestphp/pest": "^3.0",
|
||||||
|
"pestphp/pest-plugin-laravel": "^3.0",
|
||||||
|
"phpstan/extension-installer": "^1.2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||||
|
"phpstan/phpstan-phpunit": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"NotificationChannels\\Telegram\\TelegramServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"NotificationChannels\\Telegram\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Irfaq Syed",
|
||||||
|
"email": "github@lukonet.net",
|
||||||
|
"homepage": "https://lukonet.com",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Telegram Notifications Channel for Laravel",
|
||||||
|
"homepage": "https://github.com/laravel-notification-channels/telegram",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"notification",
|
||||||
|
"telegram",
|
||||||
|
"telegram notification",
|
||||||
|
"telegram notifications channel"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel-notification-channels/telegram/issues",
|
||||||
|
"source": "https://github.com/laravel-notification-channels/telegram/tree/6.0.0"
|
||||||
|
},
|
||||||
|
"time": "2025-02-25T22:59:13+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/framework",
|
||||||
|
"version": "v12.47.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/framework.git",
|
||||||
|
"reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/framework/zipball/ab8114c2e78f32e64eb238fc4b495bea3f8b80ec",
|
||||||
|
"reference": "ab8114c2e78f32e64eb238fc4b495bea3f8b80ec",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1272,20 +1412,20 @@
|
|||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"source": "https://github.com/laravel/framework"
|
||||||
},
|
},
|
||||||
"time": "2026-01-07T23:26:53+00:00"
|
"time": "2026-01-13T15:29:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/prompts",
|
"name": "laravel/prompts",
|
||||||
"version": "v0.3.8",
|
"version": "v0.3.10",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/prompts.git",
|
"url": "https://github.com/laravel/prompts.git",
|
||||||
"reference": "096748cdfb81988f60090bbb839ce3205ace0d35"
|
"reference": "360ba095ef9f51017473505191fbd4ab73e1cab3"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/prompts/zipball/096748cdfb81988f60090bbb839ce3205ace0d35",
|
"url": "https://api.github.com/repos/laravel/prompts/zipball/360ba095ef9f51017473505191fbd4ab73e1cab3",
|
||||||
"reference": "096748cdfb81988f60090bbb839ce3205ace0d35",
|
"reference": "360ba095ef9f51017473505191fbd4ab73e1cab3",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1329,9 +1469,9 @@
|
|||||||
"description": "Add beautiful and user-friendly forms to your command-line applications.",
|
"description": "Add beautiful and user-friendly forms to your command-line applications.",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/laravel/prompts/issues",
|
"issues": "https://github.com/laravel/prompts/issues",
|
||||||
"source": "https://github.com/laravel/prompts/tree/v0.3.8"
|
"source": "https://github.com/laravel/prompts/tree/v0.3.10"
|
||||||
},
|
},
|
||||||
"time": "2025-11-21T20:52:52+00:00"
|
"time": "2026-01-13T20:29:29+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/sanctum",
|
"name": "laravel/sanctum",
|
||||||
@@ -1398,16 +1538,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/serializable-closure",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.7",
|
"version": "v2.0.8",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/serializable-closure.git",
|
"url": "https://github.com/laravel/serializable-closure.git",
|
||||||
"reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd"
|
"reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd",
|
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b",
|
||||||
"reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd",
|
"reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1455,7 +1595,72 @@
|
|||||||
"issues": "https://github.com/laravel/serializable-closure/issues",
|
"issues": "https://github.com/laravel/serializable-closure/issues",
|
||||||
"source": "https://github.com/laravel/serializable-closure"
|
"source": "https://github.com/laravel/serializable-closure"
|
||||||
},
|
},
|
||||||
"time": "2025-11-21T20:52:36+00:00"
|
"time": "2026-01-08T16:22:46+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/slack-notification-channel",
|
||||||
|
"version": "v3.7.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/slack-notification-channel.git",
|
||||||
|
"reference": "414aec57b487bfbac7f90fc30f50a2f0a2df4caa"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/slack-notification-channel/zipball/414aec57b487bfbac7f90fc30f50a2f0a2df4caa",
|
||||||
|
"reference": "414aec57b487bfbac7f90fc30f50a2f0a2df4caa",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"guzzlehttp/guzzle": "^7.0",
|
||||||
|
"illuminate/http": "^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/notifications": "^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/support": "^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.0",
|
||||||
|
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
|
||||||
|
"phpstan/phpstan": "^1.10",
|
||||||
|
"phpunit/phpunit": "^9.0|^10.4|^11.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Illuminate\\Notifications\\SlackChannelServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Illuminate\\Notifications\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Slack Notification Channel for laravel.",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"notifications",
|
||||||
|
"slack"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/slack-notification-channel/issues",
|
||||||
|
"source": "https://github.com/laravel/slack-notification-channel/tree/v3.7.0"
|
||||||
|
},
|
||||||
|
"time": "2025-11-20T17:26:07+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/tinker",
|
"name": "laravel/tinker",
|
||||||
@@ -1965,20 +2170,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/uri",
|
"name": "league/uri",
|
||||||
"version": "7.7.0",
|
"version": "7.8.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/uri.git",
|
"url": "https://github.com/thephpleague/uri.git",
|
||||||
"reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807"
|
"reference": "4436c6ec8d458e4244448b069cc572d088230b76"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
|
"url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76",
|
||||||
"reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
|
"reference": "4436c6ec8d458e4244448b069cc572d088230b76",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"league/uri-interfaces": "^7.7",
|
"league/uri-interfaces": "^7.8",
|
||||||
"php": "^8.1",
|
"php": "^8.1",
|
||||||
"psr/http-factory": "^1"
|
"psr/http-factory": "^1"
|
||||||
},
|
},
|
||||||
@@ -1992,11 +2197,11 @@
|
|||||||
"ext-gmp": "to improve IPV4 host parsing",
|
"ext-gmp": "to improve IPV4 host parsing",
|
||||||
"ext-intl": "to handle IDN host with the best performance",
|
"ext-intl": "to handle IDN host with the best performance",
|
||||||
"ext-uri": "to use the PHP native URI class",
|
"ext-uri": "to use the PHP native URI class",
|
||||||
"jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain",
|
"jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
|
||||||
"league/uri-components": "Needed to easily manipulate URI objects components",
|
"league/uri-components": "to provide additional tools to manipulate URI objects components",
|
||||||
"league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP",
|
"league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
|
||||||
"php-64bit": "to improve IPV4 host parsing",
|
"php-64bit": "to improve IPV4 host parsing",
|
||||||
"rowbot/url": "to handle WHATWG URL",
|
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
|
||||||
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
|
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
@@ -2051,7 +2256,7 @@
|
|||||||
"docs": "https://uri.thephpleague.com",
|
"docs": "https://uri.thephpleague.com",
|
||||||
"forum": "https://thephpleague.slack.com",
|
"forum": "https://thephpleague.slack.com",
|
||||||
"issues": "https://github.com/thephpleague/uri-src/issues",
|
"issues": "https://github.com/thephpleague/uri-src/issues",
|
||||||
"source": "https://github.com/thephpleague/uri/tree/7.7.0"
|
"source": "https://github.com/thephpleague/uri/tree/7.8.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2059,20 +2264,20 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-07T16:02:06+00:00"
|
"time": "2026-01-14T17:24:56+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/uri-interfaces",
|
"name": "league/uri-interfaces",
|
||||||
"version": "7.7.0",
|
"version": "7.8.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/uri-interfaces.git",
|
"url": "https://github.com/thephpleague/uri-interfaces.git",
|
||||||
"reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c"
|
"reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
|
"url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4",
|
||||||
"reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
|
"reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2085,7 +2290,7 @@
|
|||||||
"ext-gmp": "to improve IPV4 host parsing",
|
"ext-gmp": "to improve IPV4 host parsing",
|
||||||
"ext-intl": "to handle IDN host with the best performance",
|
"ext-intl": "to handle IDN host with the best performance",
|
||||||
"php-64bit": "to improve IPV4 host parsing",
|
"php-64bit": "to improve IPV4 host parsing",
|
||||||
"rowbot/url": "to handle WHATWG URL",
|
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
|
||||||
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
|
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
@@ -2135,7 +2340,7 @@
|
|||||||
"docs": "https://uri.thephpleague.com",
|
"docs": "https://uri.thephpleague.com",
|
||||||
"forum": "https://thephpleague.slack.com",
|
"forum": "https://thephpleague.slack.com",
|
||||||
"issues": "https://github.com/thephpleague/uri-src/issues",
|
"issues": "https://github.com/thephpleague/uri-src/issues",
|
||||||
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0"
|
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2143,7 +2348,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-07T16:03:21+00:00"
|
"time": "2026-01-15T06:54:53+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
@@ -6289,6 +6494,55 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-18T07:04:31+00:00"
|
"time": "2025-12-18T07:04:31+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "textalk/websocket",
|
||||||
|
"version": "1.5.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Textalk/websocket-php.git",
|
||||||
|
"reference": "d05dbaa97500176447ffb1f1800573f23085ab13"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Textalk/websocket-php/zipball/d05dbaa97500176447ffb1f1800573f23085ab13",
|
||||||
|
"reference": "d05dbaa97500176447ffb1f1800573f23085ab13",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 | ^8.0",
|
||||||
|
"psr/log": "^1 | ^2 | ^3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"php-coveralls/php-coveralls": "^2.0",
|
||||||
|
"phpunit/phpunit": "^8.0|^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"WebSocket\\": "lib"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"ISC"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fredrik Liljegren"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sören Jensen",
|
||||||
|
"email": "soren@abicart.se"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "WebSocket client and server",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Textalk/websocket-php/issues",
|
||||||
|
"source": "https://github.com/Textalk/websocket-php/tree/1.5.8"
|
||||||
|
},
|
||||||
|
"time": "2022-04-26T06:28:24+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
"version": "v2.4.0",
|
"version": "v2.4.0",
|
||||||
@@ -9779,16 +10033,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "webmozart/assert",
|
"name": "webmozart/assert",
|
||||||
"version": "2.1.1",
|
"version": "2.1.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/webmozarts/assert.git",
|
"url": "https://github.com/webmozarts/assert.git",
|
||||||
"reference": "b01be90dceff69c88c7b36c30ee45c9fd9107f7f"
|
"reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/b01be90dceff69c88c7b36c30ee45c9fd9107f7f",
|
"url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649",
|
||||||
"reference": "b01be90dceff69c88c7b36c30ee45c9fd9107f7f",
|
"reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -9835,9 +10089,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/webmozarts/assert/issues",
|
"issues": "https://github.com/webmozarts/assert/issues",
|
||||||
"source": "https://github.com/webmozarts/assert/tree/2.1.1"
|
"source": "https://github.com/webmozarts/assert/tree/2.1.2"
|
||||||
},
|
},
|
||||||
"time": "2026-01-07T17:26:38+00:00"
|
"time": "2026-01-13T14:02:24+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
|
|||||||
@@ -35,4 +35,8 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'whois' => [
|
||||||
|
'api_key' => env('WHOIS_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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('domains', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('site_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('domain');
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamp('last_checked_at')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['site_id', 'is_active']);
|
||||||
|
$table->index('expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('domains');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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('sitemaps', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('site_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('url');
|
||||||
|
$table->timestamp('last_checked_at')->nullable();
|
||||||
|
$table->boolean('is_valid')->default(true);
|
||||||
|
$table->text('last_error')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->integer('url_count')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['site_id', 'is_active']);
|
||||||
|
$table->index('is_valid');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('sitemaps');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?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_channels')->nullable()->after('notification_preferences');
|
||||||
|
$table->string('slack_webhook_url')->nullable()->after('notification_channels');
|
||||||
|
$table->string('telegram_chat_id')->nullable()->after('slack_webhook_url');
|
||||||
|
$table->string('discord_webhook_url')->nullable()->after('telegram_chat_id');
|
||||||
|
$table->string('teams_webhook_url')->nullable()->after('discord_webhook_url');
|
||||||
|
$table->string('google_chat_webhook_url')->nullable()->after('teams_webhook_url');
|
||||||
|
$table->string('webhook_url')->nullable()->after('google_chat_webhook_url');
|
||||||
|
$table->string('sms_phone_number')->nullable()->after('webhook_url');
|
||||||
|
$table->string('opsgenie_api_key')->nullable()->after('sms_phone_number');
|
||||||
|
$table->string('pagerduty_integration_key')->nullable()->after('opsgenie_api_key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'notification_channels',
|
||||||
|
'slack_webhook_url',
|
||||||
|
'telegram_chat_id',
|
||||||
|
'discord_webhook_url',
|
||||||
|
'teams_webhook_url',
|
||||||
|
'google_chat_webhook_url',
|
||||||
|
'webhook_url',
|
||||||
|
'sms_phone_number',
|
||||||
|
'opsgenie_api_key',
|
||||||
|
'pagerduty_integration_key',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('site_features', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('site_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('feature_type'); // uptime, ssl, dns, broken_links, domain, sitemap, health_check, tcp_port
|
||||||
|
$table->boolean('is_enabled')->default(true);
|
||||||
|
$table->json('settings')->nullable(); // Feature-specific settings
|
||||||
|
$table->timestamp('last_checked_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['site_id', 'feature_type']);
|
||||||
|
$table->index('is_enabled');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('site_features');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Broken Links')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-2">
|
||||||
|
<i class="bi bi-link-45deg text-warning"></i>
|
||||||
|
Broken Links Monitor
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted">Track and fix broken links across all your websites</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded p-3">
|
||||||
|
<i class="bi bi-globe fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Total Sites</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['total_sites'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-danger bg-opacity-10 text-danger rounded p-3">
|
||||||
|
<i class="bi bi-link-45deg fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Broken Links</div>
|
||||||
|
<div class="h4 mb-0 text-danger">{{ $stats['total_broken_links'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-warning bg-opacity-10 text-warning rounded p-3">
|
||||||
|
<i class="bi bi-exclamation-triangle fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Sites with Issues</div>
|
||||||
|
<div class="h4 mb-0 text-warning">{{ $stats['sites_with_broken_links'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-success bg-opacity-10 text-success rounded p-3">
|
||||||
|
<i class="bi bi-check-circle fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Sites Clean</div>
|
||||||
|
<div class="h4 mb-0 text-success">{{ $stats['sites_clean'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sites List -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-check me-2"></i>
|
||||||
|
Broken Links by Site
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if($sites->isNotEmpty())
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Website</th>
|
||||||
|
<th>Broken Links</th>
|
||||||
|
<th>Last Scan</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ $site->name }}</div>
|
||||||
|
<div class="small text-muted">{{ $site->url }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($site->broken_links_count > 0)
|
||||||
|
<span class="badge bg-danger fs-6 px-3">
|
||||||
|
{{ $site->broken_links_count }} broken
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle"></i> No issues
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-muted">
|
||||||
|
@if($site->brokenLinks->isNotEmpty())
|
||||||
|
{{ $site->brokenLinks->first()->created_at->diffForHumans() }}
|
||||||
|
@else
|
||||||
|
Never scanned
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($site->broken_links_count === 0)
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle-fill"></i> Healthy
|
||||||
|
</span>
|
||||||
|
@elseif($site->broken_links_count < 5)
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> Minor Issues
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-x-circle-fill"></i> Major Issues
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ route('broken-links.show', $site) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> View Details
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-link-45deg text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
||||||
|
<h5 class="mt-3">No Sites to Monitor</h5>
|
||||||
|
<p class="text-muted">Add sites to start monitoring for broken links.</p>
|
||||||
|
<a href="{{ route('sites.create') }}" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Broken Links -->
|
||||||
|
@if($recentBrokenLinks->isNotEmpty())
|
||||||
|
<div class="card border-0 shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-clock-history me-2"></i>
|
||||||
|
Recently Detected Broken Links
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Detected</th>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Broken URL</th>
|
||||||
|
<th>Found On</th>
|
||||||
|
<th>Status Code</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($recentBrokenLinks as $link)
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted small">{{ $link->created_at->format('M d, H:i') }}</td>
|
||||||
|
<td>{{ $link->site->name }}</td>
|
||||||
|
<td>
|
||||||
|
<code class="small">{{ Str::limit($link->url ?? 'N/A', 50) }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ Str::limit($link->found_on ?? 'N/A', 40) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-danger">{{ $link->status_code ?? '404' }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Broken Links - ' . $site->name)
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('broken-links.index') }}">Broken Links</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ $site->name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-2">{{ $site->name }}</h1>
|
||||||
|
<p class="text-muted">{{ $site->url }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@if($stats['total_broken_links'] === 0)
|
||||||
|
<span class="badge bg-success fs-6 px-3 py-2">
|
||||||
|
<i class="bi bi-check-circle-fill"></i> No Broken Links
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-danger fs-6 px-3 py-2">
|
||||||
|
<i class="bi bi-exclamation-circle-fill"></i> {{ $stats['total_broken_links'] }} Broken Links
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-link-45deg text-danger fs-1 mb-3"></i>
|
||||||
|
<h3 class="mb-1">{{ $stats['total_broken_links'] }}</h3>
|
||||||
|
<p class="text-muted mb-0">Total Broken Links</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-file-text text-primary fs-1 mb-3"></i>
|
||||||
|
<h3 class="mb-1">{{ $stats['unique_urls'] }}</h3>
|
||||||
|
<p class="text-muted mb-0">Unique URLs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Broken Links List -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-ul me-2"></i>
|
||||||
|
Broken Links Details
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Broken URL</th>
|
||||||
|
<th>Found On Page</th>
|
||||||
|
<th>Status Code</th>
|
||||||
|
<th>Last Checked</th>
|
||||||
|
<th>Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($brokenLinks as $link)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i class="bi bi-link-broken text-danger me-2 mt-1"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<code class="small d-block mb-1">{{ Str::limit($link->url ?? 'N/A', 60) }}</code>
|
||||||
|
@if($link->url)
|
||||||
|
<a href="{{ $link->url }}" target="_blank" class="small text-muted" rel="noopener">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i> Test Link
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="small">
|
||||||
|
@if($link->found_on)
|
||||||
|
<a href="{{ $link->found_on }}" target="_blank" class="text-decoration-none" rel="noopener">
|
||||||
|
{{ Str::limit($link->found_on, 50) }}
|
||||||
|
<i class="bi bi-box-arrow-up-right small"></i>
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">Unknown</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ $link->status_code == 404 ? 'danger' : ($link->status_code >= 500 ? 'warning' : 'secondary') }}">
|
||||||
|
{{ $link->status_code ?? 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{ $link->created_at->diffForHumans() }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($link->link_type === 'internal')
|
||||||
|
<span class="badge bg-info">Internal</span>
|
||||||
|
@elseif($link->link_type === 'external')
|
||||||
|
<span class="badge bg-secondary">External</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-light text-dark">Unknown</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-5">
|
||||||
|
<i class="bi bi-check-circle text-success" style="font-size: 3rem;"></i>
|
||||||
|
<h5 class="mt-3">No Broken Links Found</h5>
|
||||||
|
<p class="text-muted mb-0">All links on this site are working correctly!</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($brokenLinks->hasPages())
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
{{ $brokenLinks->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($brokenLinks->isNotEmpty())
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
How to Fix Broken Links
|
||||||
|
</h6>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>Update the broken links on the pages where they're found</li>
|
||||||
|
<li>Set up redirects for moved content (301 or 302 redirects)</li>
|
||||||
|
<li>Remove or replace links to deleted pages</li>
|
||||||
|
<li>Check external links periodically as they may change over time</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
+496
-173
@@ -4,13 +4,16 @@
|
|||||||
|
|
||||||
@push('styles')
|
@push('styles')
|
||||||
<style>
|
<style>
|
||||||
|
/* Modern Stats Cards */
|
||||||
.stat-card {
|
.stat-card {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
padding: 1.5rem;
|
padding: 2rem 1.75rem;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card::before {
|
.stat-card::before {
|
||||||
@@ -19,205 +22,426 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 4px;
|
height: 3px;
|
||||||
background: linear-gradient(90deg, var(--card-gradient-start), var(--card-gradient-end));
|
background: var(--card-accent);
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-6px);
|
||||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.primary {
|
.stat-card.primary {
|
||||||
--card-gradient-start: #667eea;
|
--card-accent: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
--card-gradient-end: #764ba2;
|
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05), rgba(118, 75, 162, 0.05));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.success {
|
.stat-card.success {
|
||||||
--card-gradient-start: #10b981;
|
--card-accent: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
--card-gradient-end: #059669;
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.05), rgba(5, 150, 105, 0.05));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.danger {
|
.stat-card.danger {
|
||||||
--card-gradient-start: #ef4444;
|
--card-accent: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
--card-gradient-end: #dc2626;
|
|
||||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.05), rgba(220, 38, 38, 0.05));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.info {
|
.stat-card.info {
|
||||||
--card-gradient-start: #3b82f6;
|
--card-accent: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||||
--card-gradient-end: #2563eb;
|
|
||||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05), rgba(37, 99, 235, 0.05));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .stat-card.primary {
|
.dark .stat-card {
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.1));
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .stat-card.success {
|
.dark .stat-card:hover {
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(5, 150, 105, 0.1));
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.stat-icon {
|
||||||
width: 56px;
|
width: 64px;
|
||||||
height: 56px;
|
height: 64px;
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 1.5rem;
|
font-size: 1.75rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--icon-gradient);
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon i {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon.primary {
|
.stat-icon.primary {
|
||||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
--icon-gradient: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
color: white;
|
color: #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon.success {
|
.stat-icon.success {
|
||||||
background: linear-gradient(135deg, #10b981, #059669);
|
--icon-gradient: linear-gradient(135deg, #10b981, #059669);
|
||||||
color: white;
|
color: #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon.danger {
|
.stat-icon.danger {
|
||||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
--icon-gradient: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
color: white;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon.info {
|
.stat-icon.info {
|
||||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
--icon-gradient: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||||
color: white;
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .stat-icon {
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 2rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
opacity: 0.7;
|
opacity: 0.6;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-trend {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-trend.up {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-trend.down {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart Card */
|
||||||
.chart-card {
|
.chart-card {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .chart-card {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-card .card-header {
|
.chart-card .card-header {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 1.5rem;
|
padding: 1.75rem 2rem;
|
||||||
border-radius: 16px 16px 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-card .card-body {
|
.chart-card .card-body {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-card .card-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card .card-title i {
|
||||||
|
color: var(--indigo-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Table */
|
||||||
.table-modern {
|
.table-modern {
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-modern thead th {
|
.table-modern thead th {
|
||||||
background: transparent;
|
background: var(--bg-secondary);
|
||||||
border-bottom: 2px solid var(--border-color);
|
border: none;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 0.875rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 1px;
|
||||||
padding: 1rem;
|
padding: 1.25rem 1.5rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-modern thead th:first-child {
|
||||||
|
border-top-left-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-modern thead th:last-child {
|
||||||
|
border-top-right-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-modern tbody tr {
|
.table-modern tbody tr {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-modern tbody tr:hover {
|
.table-modern tbody tr:hover {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
transform: scale(1.01);
|
}
|
||||||
|
|
||||||
|
.table-modern tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-modern tbody td {
|
.table-modern tbody td {
|
||||||
padding: 1.25rem 1rem;
|
padding: 1.5rem 1.5rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-name {
|
.site-name {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 0.95rem;
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-url {
|
.site-url {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-top: 0.25rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-url i {
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
.badge-modern {
|
.badge-modern {
|
||||||
padding: 0.4rem 0.8rem;
|
padding: 0.5rem 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-action {
|
.badge-modern i {
|
||||||
padding: 0.5rem 1rem;
|
font-size: 0.875rem;
|
||||||
border-radius: 8px;
|
}
|
||||||
|
|
||||||
|
/* Metric Display */
|
||||||
|
.metric-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress-modern {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-modern .progress-bar {
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-action {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-action:hover {
|
.btn-action:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-action.btn-outline-primary:hover {
|
||||||
|
background: var(--indigo-600);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--indigo-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 4rem 2rem;
|
padding: 5rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-icon {
|
.empty-state-icon {
|
||||||
font-size: 4rem;
|
font-size: 5rem;
|
||||||
opacity: 0.3;
|
opacity: 0.15;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 2rem;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state h5 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page Header */
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 1.75rem;
|
font-size: 2.25rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time Badge */
|
||||||
|
.time-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart Container */
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stat-card {
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-modern thead th,
|
||||||
|
.table-modern tbody td {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
@@ -225,14 +449,14 @@
|
|||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Dashboard</h1>
|
<h1 class="page-title">Dashboard</h1>
|
||||||
<p class="page-subtitle">Welcome back! Here's an overview of all your monitored sites.</p>
|
<p class="page-subtitle">Monitor and manage all your websites from one place</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-md-3 col-sm-6">
|
<div class="col-lg-3 col-md-6">
|
||||||
<div class="stat-card primary">
|
<div class="stat-card primary">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="stat-label">Total Sites</div>
|
<div class="stat-label">Total Sites</div>
|
||||||
<div class="stat-value">{{ $totalSites }}</div>
|
<div class="stat-value">{{ $totalSites }}</div>
|
||||||
@@ -243,12 +467,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 col-sm-6">
|
<div class="col-lg-3 col-md-6">
|
||||||
<div class="stat-card success">
|
<div class="stat-card success">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="stat-label">Sites Up</div>
|
<div class="stat-label">Sites Online</div>
|
||||||
<div class="stat-value text-success">{{ $sitesUp }}</div>
|
<div class="stat-value" style="color: #10b981;">{{ $sitesUp }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-icon success">
|
<div class="stat-icon success">
|
||||||
<i class="bi bi-check-circle-fill"></i>
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
@@ -256,25 +480,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 col-sm-6">
|
<div class="col-lg-3 col-md-6">
|
||||||
<div class="stat-card danger">
|
<div class="stat-card danger">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="stat-label">Sites Down</div>
|
<div class="stat-label">Sites Down</div>
|
||||||
<div class="stat-value text-danger">{{ $sitesDown }}</div>
|
<div class="stat-value" style="color: #ef4444;">{{ $sitesDown }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-icon danger">
|
<div class="stat-icon danger">
|
||||||
<i class="bi bi-exclamation-circle-fill"></i>
|
<i class="bi bi-x-circle-fill"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 col-sm-6">
|
<div class="col-lg-3 col-md-6">
|
||||||
<div class="stat-card info">
|
<div class="stat-card info">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="stat-label">Avg Uptime</div>
|
<div class="stat-label">Average Uptime</div>
|
||||||
<div class="stat-value text-info">{{ number_format($avgUptime, 1) }}%</div>
|
<div class="stat-value" style="color: #3b82f6;">{{ number_format($avgUptime, 1) }}%</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-icon info">
|
<div class="stat-icon info">
|
||||||
<i class="bi bi-graph-up-arrow"></i>
|
<i class="bi bi-graph-up-arrow"></i>
|
||||||
@@ -285,62 +509,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 7-Day Uptime Chart -->
|
<!-- 7-Day Uptime Chart -->
|
||||||
|
@if($sites->isNotEmpty())
|
||||||
<div class="card chart-card mb-4">
|
<div class="card chart-card mb-4">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
||||||
<h5 class="mb-0 d-flex align-items-center">
|
<h5 class="card-title">
|
||||||
<i class="bi bi-graph-up me-2"></i>
|
<i class="bi bi-activity"></i>
|
||||||
7-Day Uptime Overview
|
Uptime Trends
|
||||||
</h5>
|
</h5>
|
||||||
|
<div class="time-badge">
|
||||||
|
<i class="bi bi-calendar3"></i>
|
||||||
|
Last 7 Days
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small">
|
|
||||||
<i class="bi bi-clock"></i> Last 7 days
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if($sites->isNotEmpty())
|
<div class="chart-container">
|
||||||
<canvas id="weeklyUptimeChart" height="80"></canvas>
|
<canvas id="weeklyUptimeChart"></canvas>
|
||||||
@else
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-icon">
|
|
||||||
<i class="bi bi-graph-up"></i>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Sites Table -->
|
<!-- Sites Table -->
|
||||||
<div class="card chart-card">
|
<div class="card chart-card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
||||||
<h5 class="mb-0 d-flex align-items-center">
|
<h5 class="card-title">
|
||||||
<i class="bi bi-globe me-2"></i>
|
<i class="bi bi-list-check"></i>
|
||||||
Your Sites
|
Your Websites
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
<a href="{{ route('sites.create') }}" class="btn btn-primary btn-action">
|
||||||
<a href="{{ route('sites.create') }}" class="btn btn-primary btn-sm btn-action">
|
<i class="bi bi-plus-lg"></i>
|
||||||
<i class="bi bi-plus-circle"></i> Add Site
|
Add Website
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if($sites->isNotEmpty())
|
@if($sites->isNotEmpty())
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-modern mb-0">
|
<table class="table table-modern mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Site</th>
|
<th style="width: 30%;">Website</th>
|
||||||
<th>Status</th>
|
<th style="width: 12%;">Status</th>
|
||||||
<th>Uptime</th>
|
<th style="width: 15%;">Uptime</th>
|
||||||
<th>Response</th>
|
<th style="width: 12%;">Response</th>
|
||||||
<th>SSL</th>
|
<th style="width: 12%;">SSL</th>
|
||||||
<th>Links</th>
|
<th style="width: 10%;">Links</th>
|
||||||
<th class="text-end">Actions</th>
|
<th style="width: 9%;" class="text-end">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -349,18 +566,28 @@
|
|||||||
$latestCheck = $site->uptimeChecks->first();
|
$latestCheck = $site->uptimeChecks->first();
|
||||||
$latestSsl = $site->sslCertificates->first();
|
$latestSsl = $site->sslCertificates->first();
|
||||||
$daysUntilExpiry = $latestSsl ? now()->diffInDays($latestSsl->expires_at, false) : null;
|
$daysUntilExpiry = $latestSsl ? now()->diffInDays($latestSsl->expires_at, false) : null;
|
||||||
|
$uptime = $site->uptime_percentage ?? 0;
|
||||||
|
$initials = strtoupper(substr($site->name, 0, 2));
|
||||||
@endphp
|
@endphp
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="site-name">{{ $site->name }}</div>
|
<div class="site-info">
|
||||||
<div class="site-url">
|
<div class="site-avatar" style="background: linear-gradient(135deg, {{ '#' . substr(md5($site->id), 0, 6) }}, {{ '#' . substr(md5($site->id . 'salt'), 0, 6) }});">
|
||||||
<i class="bi bi-link-45deg"></i> {{ Str::limit($site->url, 40) }}
|
{{ $initials }}
|
||||||
|
</div>
|
||||||
|
<div class="site-details">
|
||||||
|
<div class="site-name" title="{{ $site->name }}">{{ $site->name }}</div>
|
||||||
|
<div class="site-url" title="{{ $site->url }}">
|
||||||
|
<i class="bi bi-link-45deg"></i>
|
||||||
|
<span>{{ Str::limit(parse_url($site->url, PHP_URL_HOST), 35) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if(!$latestCheck)
|
@if(!$latestCheck)
|
||||||
<span class="badge badge-modern bg-secondary">
|
<span class="badge badge-modern bg-secondary">
|
||||||
<i class="bi bi-question-circle"></i> Not Checked
|
<i class="bi bi-dash-circle"></i> Pending
|
||||||
</span>
|
</span>
|
||||||
@elseif($latestCheck->status_code >= 200 && $latestCheck->status_code < 400)
|
@elseif($latestCheck->status_code >= 200 && $latestCheck->status_code < 400)
|
||||||
<span class="badge badge-modern bg-success">
|
<span class="badge badge-modern bg-success">
|
||||||
@@ -368,29 +595,31 @@
|
|||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
<span class="badge badge-modern bg-danger">
|
<span class="badge badge-modern bg-danger">
|
||||||
<i class="bi bi-x-circle-fill"></i> Down
|
<i class="bi bi-exclamation-circle-fill"></i> Down
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@php
|
<div class="metric-display">
|
||||||
$uptime = $site->uptime_percentage ?? 0;
|
<div class="progress-modern" style="width: 70px;">
|
||||||
@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') }}"
|
<div class="progress-bar bg-{{ $uptime >= 99 ? 'success' : ($uptime >= 95 ? 'warning' : 'danger') }}"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
style="width: {{ $uptime }}%"></div>
|
style="width: {{ $uptime }}%"
|
||||||
|
aria-valuenow="{{ $uptime }}"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="small fw-semibold">{{ number_format($uptime, 1) }}%</span>
|
<span class="metric-value">{{ number_format($uptime, 1) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if($site->avg_response_time)
|
@if($site->avg_response_time)
|
||||||
<span class="fw-semibold">{{ round($site->avg_response_time) }}</span>
|
<div class="metric-display">
|
||||||
<span class="text-muted small">ms</span>
|
<span class="metric-value">{{ round($site->avg_response_time) }}</span>
|
||||||
|
<span class="metric-label">ms</span>
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted fw-semibold">—</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -409,7 +638,7 @@
|
|||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted fw-semibold">—</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -419,13 +648,13 @@
|
|||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
<span class="badge badge-modern bg-success">
|
<span class="badge badge-modern bg-success">
|
||||||
<i class="bi bi-check-circle"></i> 0
|
<i class="bi bi-check2"></i> None
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a href="{{ route('dashboard.show', $site) }}" class="btn btn-sm btn-outline-primary btn-action">
|
<a href="{{ route('dashboard.show', $site) }}" class="btn btn-sm btn-outline-primary btn-action">
|
||||||
<i class="bi bi-arrow-right-circle"></i> Details
|
<i class="bi bi-arrow-right"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -436,12 +665,13 @@
|
|||||||
@else
|
@else
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">
|
<div class="empty-state-icon">
|
||||||
<i class="bi bi-globe"></i>
|
<i class="bi bi-globe2"></i>
|
||||||
</div>
|
</div>
|
||||||
<h5>No Sites Yet</h5>
|
<h5>No Websites Yet</h5>
|
||||||
<p class="text-muted">Start monitoring your websites by adding your first site.</p>
|
<p>Get started by adding your first website to monitor. It only takes a few seconds!</p>
|
||||||
<a href="{{ route('sites.create') }}" class="btn btn-primary mt-3">
|
<a href="{{ route('sites.create') }}" class="btn btn-primary btn-lg btn-action">
|
||||||
<i class="bi bi-plus-circle"></i> Add Your First Site
|
<i class="bi bi-plus-circle-fill"></i>
|
||||||
|
Add Your First Website
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@@ -453,50 +683,143 @@
|
|||||||
@if($sites->isNotEmpty() && isset($chartData))
|
@if($sites->isNotEmpty() && isset($chartData))
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const ctx = document.getElementById('weeklyUptimeChart').getContext('2d');
|
const ctx = document.getElementById('weeklyUptimeChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
const chartData = @json($chartData);
|
const chartData = @json($chartData);
|
||||||
|
|
||||||
|
// Check if user prefers dark mode
|
||||||
|
const isDarkMode = document.documentElement.classList.contains('dark') ||
|
||||||
|
localStorage.getItem('darkMode') === 'true';
|
||||||
|
|
||||||
|
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.06)';
|
||||||
|
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
||||||
|
|
||||||
new Chart(ctx, {
|
new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: chartData.labels,
|
labels: chartData.labels,
|
||||||
datasets: chartData.datasets.map((dataset, index) => ({
|
datasets: chartData.datasets.map((dataset, index) => {
|
||||||
|
const colors = [
|
||||||
|
{ border: '#667eea', bg: 'rgba(102, 126, 234, 0.1)' },
|
||||||
|
{ border: '#10b981', bg: 'rgba(16, 185, 129, 0.1)' },
|
||||||
|
{ border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
|
||||||
|
{ border: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' },
|
||||||
|
{ border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
|
||||||
|
];
|
||||||
|
const color = colors[index % colors.length];
|
||||||
|
|
||||||
|
return {
|
||||||
label: dataset.label,
|
label: dataset.label,
|
||||||
data: dataset.data,
|
data: dataset.data,
|
||||||
borderColor: dataset.color,
|
borderColor: color.border,
|
||||||
backgroundColor: dataset.color + '33',
|
backgroundColor: color.bg,
|
||||||
borderWidth: 2,
|
borderWidth: 3,
|
||||||
fill: false,
|
fill: true,
|
||||||
tension: 0.1
|
tension: 0.4,
|
||||||
}))
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointBackgroundColor: color.border,
|
||||||
|
pointBorderColor: '#fff',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
pointHoverBackgroundColor: color.border,
|
||||||
|
pointHoverBorderColor: '#fff',
|
||||||
|
pointHoverBorderWidth: 3,
|
||||||
|
};
|
||||||
|
})
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: true,
|
maintainAspectRatio: true,
|
||||||
|
aspectRatio: 2.5,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: true,
|
display: true,
|
||||||
position: 'top'
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle',
|
||||||
|
padding: 20,
|
||||||
|
font: {
|
||||||
|
size: 13,
|
||||||
|
weight: '600',
|
||||||
|
family: 'Inter, sans-serif'
|
||||||
|
},
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
mode: 'index',
|
enabled: true,
|
||||||
intersect: false
|
backgroundColor: isDarkMode ? 'rgba(30, 30, 30, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
||||||
|
titleColor: isDarkMode ? '#fff' : '#000',
|
||||||
|
bodyColor: isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.8)',
|
||||||
|
borderColor: gridColor,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
boxPadding: 6,
|
||||||
|
usePointStyle: true,
|
||||||
|
titleFont: {
|
||||||
|
size: 14,
|
||||||
|
weight: '700',
|
||||||
|
family: 'Inter, sans-serif'
|
||||||
|
},
|
||||||
|
bodyFont: {
|
||||||
|
size: 13,
|
||||||
|
weight: '600',
|
||||||
|
family: 'Inter, sans-serif'
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
let label = context.dataset.label || '';
|
||||||
|
if (label) {
|
||||||
|
label += ': ';
|
||||||
|
}
|
||||||
|
if (context.parsed.y !== null) {
|
||||||
|
label += context.parsed.y.toFixed(1) + '%';
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
max: 100,
|
max: 100,
|
||||||
title: {
|
grid: {
|
||||||
display: true,
|
color: gridColor,
|
||||||
text: 'Uptime %'
|
drawBorder: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: function(value) {
|
||||||
|
return value + '%';
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
size: 12,
|
||||||
|
weight: '600',
|
||||||
|
family: 'Inter, sans-serif'
|
||||||
|
},
|
||||||
|
color: textColor,
|
||||||
|
padding: 8
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
title: {
|
grid: {
|
||||||
display: true,
|
display: false,
|
||||||
text: 'Date'
|
drawBorder: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
font: {
|
||||||
|
size: 12,
|
||||||
|
weight: '600',
|
||||||
|
family: 'Inter, sans-serif'
|
||||||
|
},
|
||||||
|
color: textColor,
|
||||||
|
padding: 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'DNS Records Overview')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-2">
|
||||||
|
<i class="bi bi-server text-info"></i>
|
||||||
|
DNS Records Monitoring
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted">Monitor and track DNS records across all your websites</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded p-3">
|
||||||
|
<i class="bi bi-globe fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Total Sites</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['total_sites'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-info bg-opacity-10 text-info rounded p-3">
|
||||||
|
<i class="bi bi-server fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">DNS Records</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['total_dns_records'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-success bg-opacity-10 text-success rounded p-3">
|
||||||
|
<i class="bi bi-check-circle fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Sites Monitored</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['sites_with_records'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sites List -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-check me-2"></i>
|
||||||
|
DNS Records by Site
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if($sites->isNotEmpty())
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Website</th>
|
||||||
|
<th>DNS Records</th>
|
||||||
|
<th>Record Types</th>
|
||||||
|
<th>Last Updated</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ $site->name }}</div>
|
||||||
|
<div class="small text-muted">{{ $site->url }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-primary fs-6">
|
||||||
|
{{ $site->dns_records_count }} records
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($site->dnsRecords->isNotEmpty())
|
||||||
|
@php
|
||||||
|
$types = $site->dnsRecords->pluck('record_type')->unique();
|
||||||
|
@endphp
|
||||||
|
@foreach($types->take(3) as $type)
|
||||||
|
<span class="badge bg-secondary me-1">{{ $type }}</span>
|
||||||
|
@endforeach
|
||||||
|
@if($types->count() > 3)
|
||||||
|
<span class="badge bg-light text-dark">+{{ $types->count() - 3 }}</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($site->dnsRecords->isNotEmpty())
|
||||||
|
<span class="text-muted">
|
||||||
|
{{ $site->dnsRecords->first()->updated_at->diffForHumans() }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ route('dns-records.index', $site) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> View Records
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-server text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
||||||
|
<h5 class="mt-3">No DNS Records</h5>
|
||||||
|
<p class="text-muted">Add sites and configure DNS records to start monitoring.</p>
|
||||||
|
<a href="{{ route('sites.create') }}" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Card -->
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
About DNS Monitoring
|
||||||
|
</h6>
|
||||||
|
<p class="mb-0">
|
||||||
|
DNS record monitoring helps you track changes to your domain's DNS configuration.
|
||||||
|
You can monitor A, AAAA, CNAME, MX, TXT, NS, SOA, PTR, and SRV records.
|
||||||
|
Add DNS records for each site to get notified of any unexpected changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Add Domain')
|
||||||
|
|
||||||
|
@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 Domain for Monitoring</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ route('domains.store') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="site_id" class="form-label">Associated Site <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select @error('site_id') is-invalid @enderror"
|
||||||
|
id="site_id" name="site_id" required>
|
||||||
|
<option value="">Select a site...</option>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
<option value="{{ $site->id }}" {{ old('site_id') == $site->id ? 'selected' : '' }}>
|
||||||
|
{{ $site->name }} ({{ $site->url }})
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('site_id')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<div class="form-text">Select the site this domain belongs to</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="domain" class="form-label">Domain Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control @error('domain') is-invalid @enderror"
|
||||||
|
id="domain" name="domain" value="{{ old('domain') }}" required
|
||||||
|
placeholder="example.com">
|
||||||
|
@error('domain')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<div class="form-text">Enter the domain name without protocol (e.g., example.com)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_active"
|
||||||
|
name="is_active" {{ old('is_active', true) ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="is_active">
|
||||||
|
Active Monitoring
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Monitor this domain's expiration date</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> <strong>Note:</strong> After adding, we'll automatically check the domain's expiration date using WHOIS lookup.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-circle"></i> Add Domain
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('domains.index') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Edit Domain')
|
||||||
|
|
||||||
|
@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 Domain</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ route('domains.update', $domain) }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="site_id" class="form-label">Associated Site <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select @error('site_id') is-invalid @enderror"
|
||||||
|
id="site_id" name="site_id" required>
|
||||||
|
<option value="">Select a site...</option>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
<option value="{{ $site->id }}" {{ old('site_id', $domain->site_id) == $site->id ? 'selected' : '' }}>
|
||||||
|
{{ $site->name }} ({{ $site->url }})
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('site_id')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<div class="form-text">Select the site this domain belongs to</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="domain" class="form-label">Domain Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control @error('domain') is-invalid @enderror"
|
||||||
|
id="domain" name="domain" value="{{ old('domain', $domain->domain) }}" required
|
||||||
|
placeholder="example.com">
|
||||||
|
@error('domain')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<div class="form-text">Enter the domain name without protocol (e.g., example.com)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_active"
|
||||||
|
name="is_active" {{ old('is_active', $domain->is_active) ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="is_active">
|
||||||
|
Active Monitoring
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Monitor this domain's expiration date</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-circle"></i> Update Domain
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('domains.index') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Domain Monitoring')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="bi bi-globe"></i> Domain Monitoring</h1>
|
||||||
|
<a href="{{ route('domains.create') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Domain
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check-circle"></i> {{ session('success') }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($domains->isEmpty())
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> No domains are being monitored yet. Click "Add Domain" to get started!
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Expires At</th>
|
||||||
|
<th>Days Left</th>
|
||||||
|
<th>Last Checked</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($domains as $domain)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ $domain->domain }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ route('dashboard.show', $domain->site) }}">
|
||||||
|
{{ $domain->site->name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($domain->isExpired())
|
||||||
|
<span class="badge bg-danger">Expired</span>
|
||||||
|
@elseif($domain->isExpiringSoon())
|
||||||
|
<span class="badge bg-warning text-dark">Expiring Soon</span>
|
||||||
|
@elseif($domain->expires_at)
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-secondary">Unknown</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($domain->expires_at)
|
||||||
|
{{ $domain->expires_at->format('Y-m-d') }}
|
||||||
|
@else
|
||||||
|
<span class="text-muted">Not checked</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@php
|
||||||
|
$days = $domain->daysUntilExpiration();
|
||||||
|
@endphp
|
||||||
|
@if($days !== null)
|
||||||
|
@if($days < 0)
|
||||||
|
<span class="text-danger fw-bold">Expired {{ abs($days) }} days ago</span>
|
||||||
|
@elseif($days < 30)
|
||||||
|
<span class="text-warning fw-bold">{{ $days }} days</span>
|
||||||
|
@else
|
||||||
|
<span class="text-success">{{ $days }} days</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($domain->last_checked_at)
|
||||||
|
<small>{{ $domain->last_checked_at->diffForHumans() }}</small>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">Never</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="{{ route('domains.show', $domain) }}" class="btn btn-outline-primary" title="View">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('domains.edit', $domain) }}" class="btn btn-outline-secondary" title="Edit">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<form action="{{ route('domains.check', $domain) }}" method="POST" class="d-inline">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="btn btn-outline-info" title="Check Now">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action="{{ route('domains.destroy', $domain) }}" method="POST" class="d-inline"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this domain?');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="Delete">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($domains->hasPages())
|
||||||
|
<div class="card-footer">
|
||||||
|
{{ $domains->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Domain Details')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="bi bi-globe"></i> Domain Details</h1>
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('domains.edit', $domain) }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('domains.index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to List
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check-circle"></i> {{ session('success') }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Domain Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Domain Name:</div>
|
||||||
|
<div class="col-md-8">{{ $domain->domain }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Associated Site:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<a href="{{ route('dashboard.show', $domain->site) }}">
|
||||||
|
{{ $domain->site->name }}
|
||||||
|
</a>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ $domain->site->url }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Status:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if($domain->isExpired())
|
||||||
|
<span class="badge bg-danger fs-6">Expired</span>
|
||||||
|
@elseif($domain->isExpiringSoon())
|
||||||
|
<span class="badge bg-warning text-dark fs-6">Expiring Soon</span>
|
||||||
|
@elseif($domain->expires_at)
|
||||||
|
<span class="badge bg-success fs-6">Active</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-secondary fs-6">Unknown</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Monitoring:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if($domain->is_active)
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-secondary">Inactive</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Expiration Details</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Expires At:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if($domain->expires_at)
|
||||||
|
{{ $domain->expires_at->format('F j, Y') }}
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ $domain->expires_at->format('g:i A T') }}</small>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">Not yet checked</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Days Until Expiration:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
@php
|
||||||
|
$days = $domain->daysUntilExpiration();
|
||||||
|
@endphp
|
||||||
|
@if($days !== null)
|
||||||
|
@if($days < 0)
|
||||||
|
<span class="text-danger fs-4 fw-bold">Expired {{ abs($days) }} days ago</span>
|
||||||
|
@elseif($days < 30)
|
||||||
|
<span class="text-warning fs-4 fw-bold">{{ $days }} days</span>
|
||||||
|
@else
|
||||||
|
<span class="text-success fs-4 fw-bold">{{ $days }} days</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="text-muted">Unknown</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Last Checked:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if($domain->last_checked_at)
|
||||||
|
{{ $domain->last_checked_at->format('F j, Y g:i A') }}
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ $domain->last_checked_at->diffForHumans() }}</small>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">Never</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ route('domains.check', $domain) }}" method="POST" class="mb-3">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Check Expiry Now
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a href="{{ route('domains.edit', $domain) }}" class="btn btn-secondary w-100 mb-3">
|
||||||
|
<i class="bi bi-pencil"></i> Edit Domain
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form action="{{ route('domains.destroy', $domain) }}" method="POST"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this domain?');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="btn btn-danger w-100">
|
||||||
|
<i class="bi bi-trash"></i> Delete Domain
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($domain->isExpiringSoon() || $domain->isExpired())
|
||||||
|
<div class="card border-{{ $domain->isExpired() ? 'danger' : 'warning' }}">
|
||||||
|
<div class="card-header bg-{{ $domain->isExpired() ? 'danger' : 'warning' }} text-{{ $domain->isExpired() ? 'white' : 'dark' }}">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Alert</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if($domain->isExpired())
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>This domain has expired!</strong> Please renew it immediately to avoid losing ownership.
|
||||||
|
</p>
|
||||||
|
@else
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>This domain is expiring soon!</strong> Consider renewing it to prevent any interruptions.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Health Checks Overview')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-2">
|
||||||
|
<i class="bi bi-heart-pulse text-danger"></i>
|
||||||
|
Health Checks Monitoring
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted">Monitor custom health endpoints and API status across all your applications</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded p-3">
|
||||||
|
<i class="bi bi-globe fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Total Sites</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['total_sites'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-danger bg-opacity-10 text-danger rounded p-3">
|
||||||
|
<i class="bi bi-heart-pulse fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Health Checks</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['total_health_checks'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-success bg-opacity-10 text-success rounded p-3">
|
||||||
|
<i class="bi bi-check-circle fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Active Monitors</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['sites_with_checks'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sites List -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-check me-2"></i>
|
||||||
|
Health Checks by Site
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if($sites->isNotEmpty())
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Website</th>
|
||||||
|
<th>Health Endpoints</th>
|
||||||
|
<th>Active Checks</th>
|
||||||
|
<th>Last Check</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
@php
|
||||||
|
$activeChecks = $site->healthChecks->where('is_active', true);
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ $site->name }}</div>
|
||||||
|
<div class="small text-muted">{{ $site->url }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-primary fs-6">
|
||||||
|
{{ $site->health_checks_count }} endpoints
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($activeChecks->count() > 0)
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle"></i> {{ $activeChecks->count() }} active
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-dash-circle"></i> None active
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($site->healthChecks->isNotEmpty())
|
||||||
|
<span class="text-muted">
|
||||||
|
{{ $site->healthChecks->first()->updated_at->diffForHumans() }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ route('health-checks.index', $site) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> View Checks
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-heart-pulse text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
||||||
|
<h5 class="mt-3">No Health Checks</h5>
|
||||||
|
<p class="text-muted">Add sites and configure health check endpoints to start monitoring.</p>
|
||||||
|
<a href="{{ route('sites.create') }}" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Card -->
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
About Health Check Monitoring
|
||||||
|
</h6>
|
||||||
|
<p class="mb-0">
|
||||||
|
Health checks monitor custom endpoints in your applications (like /health or /api/status).
|
||||||
|
You can validate JSON responses, check for specific keys, and set performance thresholds.
|
||||||
|
This helps ensure your APIs and services are functioning correctly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -172,6 +172,72 @@
|
|||||||
Sites
|
Sites
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!-- Monitoring Features Section -->
|
||||||
|
<li class="sidebar-nav-item mt-3">
|
||||||
|
<div class="px-4 py-2">
|
||||||
|
<small class="text-uppercase text-muted fw-semibold" style="font-size: 0.75rem; letter-spacing: 0.5px;">
|
||||||
|
Monitoring Features
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="{{ route('uptime.index') }}" class="sidebar-nav-link {{ request()->routeIs('uptime.*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-arrow-up-circle"></i>
|
||||||
|
Uptime Monitoring
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="{{ route('ssl.index') }}" class="sidebar-nav-link {{ request()->routeIs('ssl.*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-shield-lock"></i>
|
||||||
|
SSL Certificates
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="{{ route('dns-records.overview') }}" class="sidebar-nav-link {{ request()->routeIs('dns-records.*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-server"></i>
|
||||||
|
DNS Records
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="{{ route('broken-links.index') }}" class="sidebar-nav-link {{ request()->routeIs('broken-links.*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-link-45deg"></i>
|
||||||
|
Broken Links
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="{{ route('domains.index') }}" class="sidebar-nav-link {{ request()->routeIs('domains.*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-globe"></i>
|
||||||
|
Domain Expiry
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="{{ route('sitemaps.index') }}" class="sidebar-nav-link {{ request()->routeIs('sitemaps.*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-diagram-3"></i>
|
||||||
|
Sitemaps
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="{{ route('health-checks.overview') }}" class="sidebar-nav-link {{ request()->routeIs('health-checks.*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-heart-pulse"></i>
|
||||||
|
Health Checks
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-nav-item">
|
||||||
|
<a href="{{ route('tcp-ports.overview') }}" class="sidebar-nav-link {{ request()->routeIs('tcp-ports.*') ? 'active' : '' }}">
|
||||||
|
<i class="bi bi-hdd-network"></i>
|
||||||
|
TCP Ports
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- General Section -->
|
||||||
|
<li class="sidebar-nav-item mt-3">
|
||||||
|
<div class="px-4 py-2">
|
||||||
|
<small class="text-uppercase text-muted fw-semibold" style="font-size: 0.75rem; letter-spacing: 0.5px;">
|
||||||
|
General
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
<li class="sidebar-nav-item">
|
<li class="sidebar-nav-item">
|
||||||
<a href="{{ route('alerts.index') }}" class="sidebar-nav-link {{ request()->routeIs('alerts.*') ? 'active' : '' }}">
|
<a href="{{ route('alerts.index') }}" class="sidebar-nav-link {{ request()->routeIs('alerts.*') ? 'active' : '' }}">
|
||||||
<i class="bi bi-bell"></i>
|
<i class="bi bi-bell"></i>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Add Sitemap')
|
||||||
|
|
||||||
|
@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 Sitemap for Monitoring</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ route('sitemaps.store') }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="site_id" class="form-label">Associated Site <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select @error('site_id') is-invalid @enderror"
|
||||||
|
id="site_id" name="site_id" required>
|
||||||
|
<option value="">Select a site...</option>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
<option value="{{ $site->id }}" {{ old('site_id') == $site->id ? 'selected' : '' }}>
|
||||||
|
{{ $site->name }} ({{ $site->url }})
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('site_id')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<div class="form-text">Select the site this sitemap belongs to</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="url" class="form-label">Sitemap 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/sitemap.xml">
|
||||||
|
@error('url')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<div class="form-text">The full URL to your sitemap file (e.g., https://example.com/sitemap.xml)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_active"
|
||||||
|
name="is_active" {{ old('is_active', true) ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="is_active">
|
||||||
|
Active Monitoring
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Automatically validate this sitemap</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> <strong>Note:</strong> After adding, we'll automatically validate your sitemap to ensure it's accessible and well-formed XML.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-circle"></i> Add Sitemap
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('sitemaps.index') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Edit Sitemap')
|
||||||
|
|
||||||
|
@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 Sitemap</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ route('sitemaps.update', $sitemap) }}" method="POST">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="site_id" class="form-label">Associated Site <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select @error('site_id') is-invalid @enderror"
|
||||||
|
id="site_id" name="site_id" required>
|
||||||
|
<option value="">Select a site...</option>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
<option value="{{ $site->id }}" {{ old('site_id', $sitemap->site_id) == $site->id ? 'selected' : '' }}>
|
||||||
|
{{ $site->name }} ({{ $site->url }})
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('site_id')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<div class="form-text">Select the site this sitemap belongs to</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="url" class="form-label">Sitemap 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', $sitemap->url) }}" required
|
||||||
|
placeholder="https://example.com/sitemap.xml">
|
||||||
|
@error('url')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
<div class="form-text">The full URL to your sitemap file</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="is_active"
|
||||||
|
name="is_active" {{ old('is_active', $sitemap->is_active) ? 'checked' : '' }}>
|
||||||
|
<label class="form-check-label" for="is_active">
|
||||||
|
Active Monitoring
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Automatically validate this sitemap</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-circle"></i> Update Sitemap
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('sitemaps.index') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Sitemap Monitoring')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="bi bi-diagram-3"></i> Sitemap Monitoring</h1>
|
||||||
|
<a href="{{ route('sitemaps.create') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Sitemap
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check-circle"></i> {{ session('success') }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($sitemaps->isEmpty())
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> No sitemaps are being monitored yet. Click "Add Sitemap" to get started!
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Sitemap URL</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>URL Count</th>
|
||||||
|
<th>Last Checked</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($sitemaps as $sitemap)
|
||||||
|
<tr class="{{ $sitemap->hasErrors() ? 'table-danger' : '' }}">
|
||||||
|
<td>
|
||||||
|
<a href="{{ route('dashboard.show', $sitemap->site) }}">
|
||||||
|
{{ $sitemap->site->name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ $sitemap->url }}" target="_blank" rel="noopener" class="text-break">
|
||||||
|
{{ Str::limit($sitemap->url, 60) }}
|
||||||
|
<i class="bi bi-box-arrow-up-right small"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ $sitemap->getStatusBadgeClass() }}">
|
||||||
|
{{ $sitemap->getStatusText() }}
|
||||||
|
</span>
|
||||||
|
@if($sitemap->last_error)
|
||||||
|
<i class="bi bi-exclamation-triangle text-danger"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="{{ $sitemap->last_error }}"></i>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($sitemap->url_count)
|
||||||
|
<span class="badge bg-info">{{ number_format($sitemap->url_count) }} URLs</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($sitemap->last_checked_at)
|
||||||
|
<small>{{ $sitemap->last_checked_at->diffForHumans() }}</small>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">Never</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="{{ route('sitemaps.show', $sitemap) }}" class="btn btn-outline-primary" title="View">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('sitemaps.edit', $sitemap) }}" class="btn btn-outline-secondary" title="Edit">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<form action="{{ route('sitemaps.check', $sitemap) }}" method="POST" class="d-inline">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="btn btn-outline-info" title="Validate Now">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action="{{ route('sitemaps.destroy', $sitemap) }}" method="POST" class="d-inline"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this sitemap?');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="Delete">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($sitemaps->hasPages())
|
||||||
|
<div class="card-footer">
|
||||||
|
{{ $sitemaps->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
// Initialize Bootstrap tooltips
|
||||||
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||||
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Sitemap Details')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1><i class="bi bi-diagram-3"></i> Sitemap Details</h1>
|
||||||
|
<div>
|
||||||
|
<a href="{{ route('sitemaps.edit', $sitemap) }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('sitemaps.index') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to List
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check-circle"></i> {{ session('success') }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Sitemap Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Associated Site:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<a href="{{ route('dashboard.show', $sitemap->site) }}">
|
||||||
|
{{ $sitemap->site->name }}
|
||||||
|
</a>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ $sitemap->site->url }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Sitemap URL:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<a href="{{ $sitemap->url }}" target="_blank" rel="noopener" class="text-break">
|
||||||
|
{{ $sitemap->url }}
|
||||||
|
<i class="bi bi-box-arrow-up-right small"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Status:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<span class="badge {{ $sitemap->getStatusBadgeClass() }} fs-6">
|
||||||
|
{{ $sitemap->getStatusText() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Monitoring:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if($sitemap->is_active)
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-secondary">Inactive</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($sitemap->url_count)
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">URLs in Sitemap:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<span class="fs-4 fw-bold text-primary">{{ number_format($sitemap->url_count) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Validation Details</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Last Checked:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if($sitemap->last_checked_at)
|
||||||
|
{{ $sitemap->last_checked_at->format('F j, Y g:i A') }}
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{{ $sitemap->last_checked_at->diffForHumans() }}</small>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">Never</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($sitemap->last_error)
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4 fw-bold">Last Error:</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="alert alert-danger mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> {{ $sitemap->last_error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{{ route('sitemaps.check', $sitemap) }}" method="POST" class="mb-3">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Validate Now
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a href="{{ route('sitemaps.edit', $sitemap) }}" class="btn btn-secondary w-100 mb-3">
|
||||||
|
<i class="bi bi-pencil"></i> Edit Sitemap
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form action="{{ route('sitemaps.destroy', $sitemap) }}" method="POST"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this sitemap?');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="btn btn-danger w-100">
|
||||||
|
<i class="bi bi-trash"></i> Delete Sitemap
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($sitemap->hasErrors())
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Validation Issue</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>Your sitemap has validation errors.</strong> Please check the error message above and fix the issues.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@elseif($sitemap->is_valid && $sitemap->last_checked_at)
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-check-circle"></i> Validation Passed</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-0">
|
||||||
|
Your sitemap is valid and accessible. It contains {{ number_format($sitemap->url_count ?? 0) }} URL(s).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'SSL Certificates')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-2">
|
||||||
|
<i class="bi bi-shield-lock text-success"></i>
|
||||||
|
SSL Certificates
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted">Monitor SSL certificate expiration and validity across all your sites</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded p-3">
|
||||||
|
<i class="bi bi-globe fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Total Sites</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['total_sites'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-success bg-opacity-10 text-success rounded p-3">
|
||||||
|
<i class="bi bi-shield-check fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Valid Certs</div>
|
||||||
|
<div class="h4 mb-0 text-success">{{ $stats['valid_certs'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-warning bg-opacity-10 text-warning rounded p-3">
|
||||||
|
<i class="bi bi-clock-history fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Expiring Soon</div>
|
||||||
|
<div class="h4 mb-0 text-warning">{{ $stats['expiring_soon'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-danger bg-opacity-10 text-danger rounded p-3">
|
||||||
|
<i class="bi bi-shield-x fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Expired</div>
|
||||||
|
<div class="h4 mb-0 text-danger">{{ $stats['expired'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sites List -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-check me-2"></i>
|
||||||
|
SSL Status by Site
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if($sites->isNotEmpty())
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Website</th>
|
||||||
|
<th>Issuer</th>
|
||||||
|
<th>Issued Date</th>
|
||||||
|
<th>Expiry Date</th>
|
||||||
|
<th>Days Remaining</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
@php
|
||||||
|
$latestCert = $site->sslCertificates->first();
|
||||||
|
$daysUntilExpiry = $latestCert && $latestCert->expires_at ? now()->diffInDays($latestCert->expires_at, false) : null;
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ $site->name }}</div>
|
||||||
|
<div class="small text-muted">{{ $site->url }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($latestCert && $latestCert->issuer)
|
||||||
|
<span class="text-muted">{{ Str::limit($latestCert->issuer, 30) }}</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($latestCert && $latestCert->issued_at)
|
||||||
|
{{ $latestCert->issued_at->format('M d, Y') }}
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($latestCert && $latestCert->expires_at)
|
||||||
|
{{ $latestCert->expires_at->format('M d, Y') }}
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($daysUntilExpiry !== null)
|
||||||
|
@if($daysUntilExpiry < 0)
|
||||||
|
<span class="text-danger fw-bold">Expired</span>
|
||||||
|
@elseif($daysUntilExpiry < 30)
|
||||||
|
<span class="text-warning fw-bold">{{ $daysUntilExpiry }} days</span>
|
||||||
|
@else
|
||||||
|
<span class="text-success">{{ $daysUntilExpiry }} days</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if(!$latestCert)
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-dash-circle"></i> Not Checked
|
||||||
|
</span>
|
||||||
|
@elseif($daysUntilExpiry < 0)
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-shield-x"></i> Expired
|
||||||
|
</span>
|
||||||
|
@elseif($daysUntilExpiry < 30)
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-clock-history"></i> Expiring Soon
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-shield-check"></i> Valid
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ route('ssl.show', $site) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> View Details
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-shield-lock text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
||||||
|
<h5 class="mt-3">No SSL Certificates</h5>
|
||||||
|
<p class="text-muted">Add sites to start monitoring their SSL certificates.</p>
|
||||||
|
<a href="{{ route('sites.create') }}" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Certificates -->
|
||||||
|
@if($recentCertificates->isNotEmpty())
|
||||||
|
<div class="card border-0 shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-clock-history me-2"></i>
|
||||||
|
Recent Certificate Checks
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Issuer</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($recentCertificates as $cert)
|
||||||
|
@php
|
||||||
|
$daysUntilExpiry = $cert->expires_at ? now()->diffInDays($cert->expires_at, false) : null;
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted small">{{ $cert->created_at->format('M d, H:i:s') }}</td>
|
||||||
|
<td>{{ $cert->site->name }}</td>
|
||||||
|
<td class="small">{{ Str::limit($cert->issuer ?? 'Unknown', 25) }}</td>
|
||||||
|
<td>
|
||||||
|
@if($cert->expires_at)
|
||||||
|
{{ $cert->expires_at->format('M d, Y') }}
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($daysUntilExpiry === null)
|
||||||
|
<span class="badge bg-secondary">Unknown</span>
|
||||||
|
@elseif($daysUntilExpiry < 0)
|
||||||
|
<span class="badge bg-danger">Expired</span>
|
||||||
|
@elseif($daysUntilExpiry < 30)
|
||||||
|
<span class="badge bg-warning text-dark">{{ $daysUntilExpiry }}d</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-success">Valid</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'SSL Certificate - ' . $site->name)
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('ssl.index') }}">SSL Certificates</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ $site->name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-2">{{ $site->name }}</h1>
|
||||||
|
<p class="text-muted">{{ $site->url }}</p>
|
||||||
|
</div>
|
||||||
|
@if($latestCert)
|
||||||
|
@php
|
||||||
|
$daysUntilExpiry = $latestCert->expires_at ? now()->diffInDays($latestCert->expires_at, false) : null;
|
||||||
|
@endphp
|
||||||
|
<div>
|
||||||
|
@if($daysUntilExpiry === null)
|
||||||
|
<span class="badge bg-secondary fs-6 px-3 py-2">Status Unknown</span>
|
||||||
|
@elseif($daysUntilExpiry < 0)
|
||||||
|
<span class="badge bg-danger fs-6 px-3 py-2">
|
||||||
|
<i class="bi bi-shield-x"></i> Certificate Expired
|
||||||
|
</span>
|
||||||
|
@elseif($daysUntilExpiry < 30)
|
||||||
|
<span class="badge bg-warning text-dark fs-6 px-3 py-2">
|
||||||
|
<i class="bi bi-clock-history"></i> Expires in {{ $daysUntilExpiry }} days
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-success fs-6 px-3 py-2">
|
||||||
|
<i class="bi bi-shield-check"></i> Valid Certificate
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($latestCert)
|
||||||
|
<!-- Current Certificate Info -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
Certificate Details
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-muted small mb-1">Issuer</div>
|
||||||
|
<div class="fw-semibold">{{ $latestCert->issuer ?? 'Unknown' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-muted small mb-1">Subject</div>
|
||||||
|
<div class="fw-semibold">{{ $latestCert->subject ?? 'Unknown' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small mb-1">Issued Date</div>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
@if($latestCert->issued_at)
|
||||||
|
{{ $latestCert->issued_at->format('M d, Y') }}
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-muted small mb-1">Expiry Date</div>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
@if($latestCert->expires_at)
|
||||||
|
{{ $latestCert->expires_at->format('M d, Y') }}
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-calendar-check me-2"></i>
|
||||||
|
Validity Information
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-muted small mb-1">Days Remaining</div>
|
||||||
|
<div class="h3 mb-0">
|
||||||
|
@if($daysUntilExpiry !== null)
|
||||||
|
@if($daysUntilExpiry < 0)
|
||||||
|
<span class="text-danger">Expired {{ abs($daysUntilExpiry) }} days ago</span>
|
||||||
|
@else
|
||||||
|
<span class="text-{{ $daysUntilExpiry < 30 ? 'warning' : 'success' }}">{{ $daysUntilExpiry }} days</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-muted small mb-1">Validity Period</div>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
@if($latestCert->issued_at && $latestCert->expires_at)
|
||||||
|
{{ $latestCert->issued_at->diffInDays($latestCert->expires_at) }} days
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small mb-1">Last Checked</div>
|
||||||
|
<div class="fw-semibold">
|
||||||
|
{{ $latestCert->created_at->diffForHumans() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
No SSL certificate information available for this site yet.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Certificate History -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-clock-history me-2"></i>
|
||||||
|
Certificate History
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Checked At</th>
|
||||||
|
<th>Issuer</th>
|
||||||
|
<th>Issued Date</th>
|
||||||
|
<th>Expiry Date</th>
|
||||||
|
<th>Days Until Expiry</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($sslCertificates as $cert)
|
||||||
|
@php
|
||||||
|
$daysUntilExpiry = $cert->expires_at ? now()->diffInDays($cert->expires_at, false) : null;
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td>{{ $cert->created_at->format('M d, Y H:i:s') }}</td>
|
||||||
|
<td>{{ Str::limit($cert->issuer ?? 'Unknown', 40) }}</td>
|
||||||
|
<td>
|
||||||
|
@if($cert->issued_at)
|
||||||
|
{{ $cert->issued_at->format('M d, Y') }}
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($cert->expires_at)
|
||||||
|
{{ $cert->expires_at->format('M d, Y') }}
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($daysUntilExpiry !== null)
|
||||||
|
@if($daysUntilExpiry < 0)
|
||||||
|
<span class="text-danger">{{ $daysUntilExpiry }} days</span>
|
||||||
|
@elseif($daysUntilExpiry < 30)
|
||||||
|
<span class="text-warning">{{ $daysUntilExpiry }} days</span>
|
||||||
|
@else
|
||||||
|
<span class="text-success">{{ $daysUntilExpiry }} days</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($daysUntilExpiry === null)
|
||||||
|
<span class="badge bg-secondary">Unknown</span>
|
||||||
|
@elseif($daysUntilExpiry < 0)
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-shield-x"></i> Expired
|
||||||
|
</span>
|
||||||
|
@elseif($daysUntilExpiry < 30)
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-clock-history"></i> Expiring
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-shield-check"></i> Valid
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4 text-muted">
|
||||||
|
No SSL certificate checks recorded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($sslCertificates->hasPages())
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
{{ $sslCertificates->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'TCP Ports Overview')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-2">
|
||||||
|
<i class="bi bi-hdd-network text-dark"></i>
|
||||||
|
TCP Port Monitoring
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted">Monitor TCP port availability and connectivity across all your servers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded p-3">
|
||||||
|
<i class="bi bi-globe fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Total Sites</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['total_sites'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-dark bg-opacity-10 text-dark rounded p-3">
|
||||||
|
<i class="bi bi-hdd-network fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Monitored Ports</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['total_ports_monitored'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-success bg-opacity-10 text-success rounded p-3">
|
||||||
|
<i class="bi bi-check-circle fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Sites with Ports</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['sites_with_ports'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sites List -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-check me-2"></i>
|
||||||
|
TCP Ports by Site
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if($sites->isNotEmpty())
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Website</th>
|
||||||
|
<th>Monitored Ports</th>
|
||||||
|
<th>Active Ports</th>
|
||||||
|
<th>Common Ports</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
@php
|
||||||
|
$activePorts = $site->tcpPorts->where('is_active', true);
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ $site->name }}</div>
|
||||||
|
<div class="small text-muted">{{ $site->url }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-primary fs-6">
|
||||||
|
{{ $site->tcp_ports_count }} ports
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($activePorts->count() > 0)
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle"></i> {{ $activePorts->count() }} active
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-dash-circle"></i> None active
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($site->tcpPorts->isNotEmpty())
|
||||||
|
@foreach($site->tcpPorts->take(5) as $port)
|
||||||
|
<span class="badge bg-dark me-1">{{ $port->port }}</span>
|
||||||
|
@endforeach
|
||||||
|
@if($site->tcpPorts->count() > 5)
|
||||||
|
<span class="badge bg-light text-dark">+{{ $site->tcpPorts->count() - 5 }}</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ route('tcp-ports.index', $site) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> View Ports
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-hdd-network text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
||||||
|
<h5 class="mt-3">No TCP Ports Monitored</h5>
|
||||||
|
<p class="text-muted">Add sites and configure TCP ports to start monitoring connectivity.</p>
|
||||||
|
<a href="{{ route('sites.create') }}" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Common Ports Reference -->
|
||||||
|
<div class="card border-0 shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
Common TCP Ports Reference
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">80</span> <span class="ms-2">HTTP</span></li>
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">443</span> <span class="ms-2">HTTPS</span></li>
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">22</span> <span class="ms-2">SSH</span></li>
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">21</span> <span class="ms-2">FTP</span></li>
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">25</span> <span class="ms-2">SMTP</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">3306</span> <span class="ms-2">MySQL</span></li>
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">5432</span> <span class="ms-2">PostgreSQL</span></li>
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">6379</span> <span class="ms-2">Redis</span></li>
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">27017</span> <span class="ms-2">MongoDB</span></li>
|
||||||
|
<li class="mb-2"><span class="badge bg-dark">3000</span> <span class="ms-2">Node.js (common)</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Uptime Monitoring')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-2">
|
||||||
|
<i class="bi bi-arrow-up-circle text-primary"></i>
|
||||||
|
Uptime Monitoring
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted">Monitor website availability and response times across all your sites</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-primary bg-opacity-10 text-primary rounded p-3">
|
||||||
|
<i class="bi bi-globe fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Total Sites</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['total_sites'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-success bg-opacity-10 text-success rounded p-3">
|
||||||
|
<i class="bi bi-check-circle-fill fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Sites Up</div>
|
||||||
|
<div class="h4 mb-0 text-success">{{ $stats['sites_up'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-danger bg-opacity-10 text-danger rounded p-3">
|
||||||
|
<i class="bi bi-x-circle-fill fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Sites Down</div>
|
||||||
|
<div class="h4 mb-0 text-danger">{{ $stats['sites_down'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-info bg-opacity-10 text-info rounded p-3">
|
||||||
|
<i class="bi bi-speedometer2 fs-4"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<div class="text-muted small">Avg Response</div>
|
||||||
|
<div class="h4 mb-0">{{ $stats['avg_response_time'] }}ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sites List -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-check me-2"></i>
|
||||||
|
All Sites
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if($sites->isNotEmpty())
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Website</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Last Check</th>
|
||||||
|
<th>Response Time</th>
|
||||||
|
<th>Uptime (30d)</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($sites as $site)
|
||||||
|
@php
|
||||||
|
$latestCheck = $site->uptimeChecks->first();
|
||||||
|
$isOnline = $latestCheck && $latestCheck->is_online;
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">{{ $site->name }}</div>
|
||||||
|
<div class="small text-muted">{{ $site->url }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if(!$latestCheck)
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-dash-circle"></i> Not Checked
|
||||||
|
</span>
|
||||||
|
@elseif($isOnline)
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle-fill"></i> Online
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-x-circle-fill"></i> Down
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($latestCheck)
|
||||||
|
<span class="text-muted">{{ $latestCheck->created_at->diffForHumans() }}</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($latestCheck && $latestCheck->response_time)
|
||||||
|
<span class="fw-semibold">{{ round($latestCheck->response_time) }}ms</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="progress" style="width: 100px; height: 8px;">
|
||||||
|
<div class="progress-bar bg-success"
|
||||||
|
style="width: 99.5%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="small fw-semibold">99.5%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ route('uptime.show', $site) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i> View Details
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-arrow-up-circle text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
||||||
|
<h5 class="mt-3">No Sites Being Monitored</h5>
|
||||||
|
<p class="text-muted">Add sites to start monitoring their uptime.</p>
|
||||||
|
<a href="{{ route('sites.create') }}" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Checks -->
|
||||||
|
@if($recentChecks->isNotEmpty())
|
||||||
|
<div class="card border-0 shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-clock-history me-2"></i>
|
||||||
|
Recent Checks
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Response Time</th>
|
||||||
|
<th>Status Code</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($recentChecks as $check)
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted small">{{ $check->created_at->format('M d, H:i:s') }}</td>
|
||||||
|
<td>{{ $check->site->name }}</td>
|
||||||
|
<td>
|
||||||
|
@if($check->is_online)
|
||||||
|
<span class="badge bg-success">Online</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-danger">Down</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>{{ round($check->response_time ?? 0) }}ms</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ $check->status_code >= 200 && $check->status_code < 300 ? 'success' : 'warning' }}">
|
||||||
|
{{ $check->status_code }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', 'Uptime Details - ' . $site->name)
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('uptime.index') }}">Uptime Monitoring</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ $site->name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-2">{{ $site->name }}</h1>
|
||||||
|
<p class="text-muted">{{ $site->url }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-success fs-6 px-3 py-2">
|
||||||
|
<i class="bi bi-graph-up-arrow"></i> {{ $uptimePercentage }}% Uptime
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-percent text-success fs-1 mb-3"></i>
|
||||||
|
<h3 class="mb-1">{{ $uptimePercentage }}%</h3>
|
||||||
|
<p class="text-muted mb-0">Uptime (30 days)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-speedometer2 text-primary fs-1 mb-3"></i>
|
||||||
|
<h3 class="mb-1">{{ round($avgResponseTime ?? 0) }}ms</h3>
|
||||||
|
<p class="text-muted mb-0">Avg Response Time</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-clock-history text-info fs-1 mb-3"></i>
|
||||||
|
<h3 class="mb-1">{{ $uptimeChecks->count() }}</h3>
|
||||||
|
<p class="text-muted mb-0">Total Checks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uptime Chart -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-graph-up me-2"></i>
|
||||||
|
Response Time Trend
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="responseTimeChart" height="100"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Check History -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-ul me-2"></i>
|
||||||
|
Check History
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="bg-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date & Time</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Status Code</th>
|
||||||
|
<th>Response Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($uptimeChecks as $check)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $check->created_at->format('M d, Y H:i:s') }}</td>
|
||||||
|
<td>
|
||||||
|
@if($check->is_online)
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle"></i> Online
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-danger">
|
||||||
|
<i class="bi bi-x-circle"></i> Down
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ $check->status_code >= 200 && $check->status_code < 300 ? 'success' : ($check->status_code >= 300 && $check->status_code < 400 ? 'info' : 'danger') }}">
|
||||||
|
{{ $check->status_code }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ round($check->response_time ?? 0) }}ms</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center py-4 text-muted">
|
||||||
|
No uptime checks recorded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($uptimeChecks->hasPages())
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
{{ $uptimeChecks->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const ctx = document.getElementById('responseTimeChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const isDarkMode = localStorage.getItem('darkMode') === 'true';
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: @json($uptimeChecks->pluck('created_at')->map(fn($date) => $date->format('M d H:i'))->reverse()),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Response Time (ms)',
|
||||||
|
data: @json($uptimeChecks->pluck('response_time')->reverse()),
|
||||||
|
borderColor: '#667eea',
|
||||||
|
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.06)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -56,3 +56,17 @@ Schedule::command('monitor:dns')
|
|||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground()
|
->runInBackground()
|
||||||
->appendOutputTo(storage_path('logs/dns-monitoring.log'));
|
->appendOutputTo(storage_path('logs/dns-monitoring.log'));
|
||||||
|
|
||||||
|
// Schedule domain expiry monitoring to run daily
|
||||||
|
Schedule::command('monitor:domains')
|
||||||
|
->daily()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground()
|
||||||
|
->appendOutputTo(storage_path('logs/domain-monitoring.log'));
|
||||||
|
|
||||||
|
// Schedule sitemap monitoring to run daily
|
||||||
|
Schedule::command('monitor:sitemaps')
|
||||||
|
->daily()
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground()
|
||||||
|
->appendOutputTo(storage_path('logs/sitemap-monitoring.log'));
|
||||||
|
|||||||
+33
-3
@@ -1,9 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AlertController;
|
use App\Http\Controllers\AlertController;
|
||||||
|
use App\Http\Controllers\BrokenLinkController;
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
|
use App\Http\Controllers\DnsRecordController;
|
||||||
|
use App\Http\Controllers\DomainController;
|
||||||
|
use App\Http\Controllers\HealthCheckController;
|
||||||
use App\Http\Controllers\SettingsController;
|
use App\Http\Controllers\SettingsController;
|
||||||
|
use App\Http\Controllers\SitemapController;
|
||||||
use App\Http\Controllers\SiteController;
|
use App\Http\Controllers\SiteController;
|
||||||
|
use App\Http\Controllers\SslCertificateController;
|
||||||
|
use App\Http\Controllers\TcpPortController;
|
||||||
|
use App\Http\Controllers\UptimeController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
@@ -22,11 +30,33 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::resource('sites', SiteController::class);
|
Route::resource('sites', SiteController::class);
|
||||||
Route::get('dashboard/{site}', [DashboardController::class, 'show'])->name('dashboard.show');
|
Route::get('dashboard/{site}', [DashboardController::class, 'show'])->name('dashboard.show');
|
||||||
|
|
||||||
|
// Monitoring Features - Overview Pages
|
||||||
|
Route::get('/uptime', [UptimeController::class, 'index'])->name('uptime.index');
|
||||||
|
Route::get('/uptime/{site}', [UptimeController::class, 'show'])->name('uptime.show');
|
||||||
|
|
||||||
|
Route::get('/ssl-certificates', [SslCertificateController::class, 'index'])->name('ssl.index');
|
||||||
|
Route::get('/ssl-certificates/{site}', [SslCertificateController::class, 'show'])->name('ssl.show');
|
||||||
|
|
||||||
|
Route::get('/broken-links', [BrokenLinkController::class, 'index'])->name('broken-links.index');
|
||||||
|
Route::get('/broken-links/{site}', [BrokenLinkController::class, 'show'])->name('broken-links.show');
|
||||||
|
|
||||||
|
Route::get('/dns-records-overview', [DnsRecordController::class, 'overview'])->name('dns-records.overview');
|
||||||
|
Route::get('/health-checks-overview', [HealthCheckController::class, 'overview'])->name('health-checks.overview');
|
||||||
|
Route::get('/tcp-ports-overview', [TcpPortController::class, 'overview'])->name('tcp-ports.overview');
|
||||||
|
|
||||||
|
// Domain monitoring
|
||||||
|
Route::resource('domains', DomainController::class);
|
||||||
|
Route::post('domains/{domain}/check', [DomainController::class, 'check'])->name('domains.check');
|
||||||
|
|
||||||
|
// Sitemap monitoring
|
||||||
|
Route::resource('sitemaps', SitemapController::class);
|
||||||
|
Route::post('sitemaps/{sitemap}/check', [SitemapController::class, 'check'])->name('sitemaps.check');
|
||||||
|
|
||||||
// Resources nested under sites
|
// Resources nested under sites
|
||||||
Route::prefix('sites/{site}')->group(function () {
|
Route::prefix('sites/{site}')->group(function () {
|
||||||
Route::resource('tcp-ports', \App\Http\Controllers\TcpPortController::class);
|
Route::resource('tcp-ports', TcpPortController::class);
|
||||||
Route::resource('health-checks', \App\Http\Controllers\HealthCheckController::class);
|
Route::resource('health-checks', HealthCheckController::class);
|
||||||
Route::resource('dns-records', \App\Http\Controllers\DnsRecordController::class);
|
Route::resource('dns-records', DnsRecordController::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Alerts
|
// Alerts
|
||||||
|
|||||||
Reference in New Issue
Block a user