diff --git a/API.md b/API.md new file mode 100644 index 0000000..8a970d5 --- /dev/null +++ b/API.md @@ -0,0 +1,406 @@ +# API Documentation + +This document describes the API endpoints for the site monitoring application. + +## Authentication + +All API endpoints require authentication using Laravel Sanctum tokens. You need to be logged into the web application to generate API tokens. + +### Generate an API Token + +After logging into the web application, you can generate an API token by making a POST request: + +```bash +POST /api/tokens +Authorization: Bearer {your-session-token} +Content-Type: application/json + +{ + "token_name": "My API Token" +} +``` + +**Response:** +```json +{ + "token": "1|abcdef123456...", + "type": "Bearer" +} +``` + +**Important:** Save this token securely. It will only be shown once. + +### Using the API Token + +Include the token in the `Authorization` header for all API requests: + +```bash +Authorization: Bearer 1|abcdef123456... +``` + +### Token Management + +#### List All Tokens +```bash +GET /api/tokens +Authorization: Bearer {your-token} +``` + +#### Revoke a Specific Token +```bash +DELETE /api/tokens/{tokenId} +Authorization: Bearer {your-token} +``` + +#### Revoke All Tokens +```bash +DELETE /api/tokens +Authorization: Bearer {your-token} +``` + +## API Endpoints + +### Sites + +#### List All Sites +Get all sites for the authenticated user. + +```bash +GET /api/sites +Authorization: Bearer {your-token} +``` + +**Response:** +```json +{ + "data": [ + { + "id": 1, + "user_id": 1, + "url": "https://example.com", + "name": "My Website", + "monitoring_enabled": true, + "uptime_checks_count": 150, + "ssl_certificates_count": 5, + "broken_links_count": 3, + "created_at": "2024-01-01T00:00:00.000000Z", + "updated_at": "2024-01-15T12:00:00.000000Z" + } + ] +} +``` + +#### Get a Specific Site +```bash +GET /api/sites/{site} +Authorization: Bearer {your-token} +``` + +**Response:** +```json +{ + "data": { + "id": 1, + "user_id": 1, + "url": "https://example.com", + "name": "My Website", + "monitoring_enabled": true, + "uptime_checks_count": 150, + "ssl_certificates_count": 5, + "broken_links_count": 3, + "created_at": "2024-01-01T00:00:00.000000Z", + "updated_at": "2024-01-15T12:00:00.000000Z" + } +} +``` + +### Uptime Monitoring + +#### Get Uptime History +Get paginated uptime check history for a site. + +```bash +GET /api/sites/{site}/uptime?per_page=50 +Authorization: Bearer {your-token} +``` + +**Query Parameters:** +- `per_page` (optional): Number of results per page (1-100, default: 50) + +**Response:** +```json +{ + "current_page": 1, + "data": [ + { + "id": 1, + "site_id": 1, + "status_code": 200, + "response_time_ms": 245, + "checked_at": "2024-01-15T12:00:00.000000Z", + "created_at": "2024-01-15T12:00:00.000000Z" + } + ], + "first_page_url": "http://localhost/api/sites/1/uptime?page=1", + "from": 1, + "last_page": 3, + "last_page_url": "http://localhost/api/sites/1/uptime?page=3", + "links": [...], + "next_page_url": "http://localhost/api/sites/1/uptime?page=2", + "path": "http://localhost/api/sites/1/uptime", + "per_page": 50, + "prev_page_url": null, + "to": 50, + "total": 150 +} +``` + +#### Get Uptime Statistics +Get uptime statistics for a site. + +```bash +GET /api/sites/{site}/uptime/stats +Authorization: Bearer {your-token} +``` + +**Response:** +```json +{ + "data": { + "total_checks": 150, + "successful_checks": 147, + "uptime_percentage": 98.0, + "average_response_time_ms": 234.56, + "latest_check": { + "id": 150, + "site_id": 1, + "status_code": 200, + "response_time_ms": 245, + "checked_at": "2024-01-15T12:00:00.000000Z", + "created_at": "2024-01-15T12:00:00.000000Z" + } + } +} +``` + +### SSL Certificate Monitoring + +#### Get SSL Certificate Data +Get the latest SSL certificate information for a site. + +```bash +GET /api/sites/{site}/ssl +Authorization: Bearer {your-token} +``` + +**Response:** +```json +{ + "data": { + "issuer": "Let's Encrypt Authority X3", + "expires_at": "2024-04-15T00:00:00.000000Z", + "checked_at": "2024-01-15T12:00:00.000000Z", + "days_until_expiry": 90, + "is_expiring_soon": false, + "is_expired": false + } +} +``` + +#### Get SSL Certificate History +Get paginated SSL certificate check history. + +```bash +GET /api/sites/{site}/ssl/history?per_page=20 +Authorization: Bearer {your-token} +``` + +**Query Parameters:** +- `per_page` (optional): Number of results per page (1-100, default: 20) + +**Response:** +```json +{ + "current_page": 1, + "data": [ + { + "id": 5, + "site_id": 1, + "issuer": "Let's Encrypt Authority X3", + "expires_at": "2024-04-15T00:00:00.000000Z", + "checked_at": "2024-01-15T12:00:00.000000Z", + "created_at": "2024-01-15T12:00:00.000000Z" + } + ], + "first_page_url": "...", + "last_page": 1, + "per_page": 20, + "total": 5 +} +``` + +### Broken Links + +#### Get Broken Links List +Get paginated list of broken links for a site. + +```bash +GET /api/sites/{site}/links?per_page=50 +Authorization: Bearer {your-token} +``` + +**Query Parameters:** +- `per_page` (optional): Number of results per page (1-100, default: 50) + +**Response:** +```json +{ + "current_page": 1, + "data": [ + { + "id": 1, + "site_id": 1, + "url": "https://example.com/broken-page", + "status_code": 404, + "last_checked_at": "2024-01-15T12:00:00.000000Z", + "created_at": "2024-01-15T12:00:00.000000Z" + } + ], + "first_page_url": "...", + "last_page": 1, + "per_page": 50, + "total": 3 +} +``` + +#### Get Broken Links Statistics +Get statistics about broken links for a site. + +```bash +GET /api/sites/{site}/links/stats +Authorization: Bearer {your-token} +``` + +**Response:** +```json +{ + "data": { + "total_broken_links": 3, + "status_code_breakdown": [ + { + "status_code": 404, + "count": 2 + }, + { + "status_code": 500, + "count": 1 + } + ] + } +} +``` + +## Error Responses + +### Unauthorized (401) +```json +{ + "message": "Unauthenticated." +} +``` + +### Forbidden (403) +```json +{ + "message": "Unauthorized" +} +``` + +### Not Found (404) +```json +{ + "message": "No SSL certificate data found" +} +``` + +### Validation Error (422) +```json +{ + "message": "The per_page field must be an integer.", + "errors": { + "per_page": [ + "The per_page field must be an integer." + ] + } +} +``` + +## Example Usage + +### Using cURL + +```bash +# List all sites +curl -X GET http://localhost/api/sites \ + -H "Authorization: Bearer 1|abcdef123456..." \ + -H "Accept: application/json" + +# Get uptime history +curl -X GET "http://localhost/api/sites/1/uptime?per_page=25" \ + -H "Authorization: Bearer 1|abcdef123456..." \ + -H "Accept: application/json" + +# Get SSL certificate data +curl -X GET http://localhost/api/sites/1/ssl \ + -H "Authorization: Bearer 1|abcdef123456..." \ + -H "Accept: application/json" +``` + +### Using JavaScript (Fetch API) + +```javascript +const apiToken = '1|abcdef123456...'; +const baseUrl = 'http://localhost/api'; + +// List all sites +const response = await fetch(`${baseUrl}/sites`, { + headers: { + 'Authorization': `Bearer ${apiToken}`, + 'Accept': 'application/json' + } +}); + +const data = await response.json(); +console.log(data); +``` + +### Using Python (requests) + +```python +import requests + +api_token = '1|abcdef123456...' +base_url = 'http://localhost/api' + +headers = { + 'Authorization': f'Bearer {api_token}', + 'Accept': 'application/json' +} + +# List all sites +response = requests.get(f'{base_url}/sites', headers=headers) +data = response.json() +print(data) +``` + +## Rate Limiting + +API requests are subject to Laravel's default rate limiting (60 requests per minute for authenticated users). If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. + +## Security Best Practices + +1. **Never share your API tokens** - Keep them secure and treat them like passwords +2. **Use HTTPS in production** - Always use encrypted connections when making API requests +3. **Rotate tokens regularly** - Periodically revoke and recreate your API tokens +4. **Use separate tokens for different applications** - Create named tokens for each integration +5. **Revoke unused tokens** - Remove tokens that are no longer needed diff --git a/app/Console/Commands/MonitorDns.php b/app/Console/Commands/MonitorDns.php new file mode 100644 index 0000000..2e6ffc3 --- /dev/null +++ b/app/Console/Commands/MonitorDns.php @@ -0,0 +1,105 @@ +info('Starting DNS monitoring...'); + + $query = DnsRecord::with('site')->where('is_active', true); + + // Filter by specific DNS record + if ($dnsRecordId = $this->option('dns-record')) { + $query->where('id', $dnsRecordId); + } + + // Filter by site + if ($siteId = $this->option('site')) { + $query->where('site_id', $siteId); + } + + $dnsRecords = $query->get(); + + if ($dnsRecords->isEmpty()) { + $this->warn('No active DNS records found.'); + return self::SUCCESS; + } + + $this->info("Found {$dnsRecords->count()} active DNS record(s)."); + + $bar = $this->output->createProgressBar($dnsRecords->count()); + $bar->start(); + + $dispatched = 0; + $errors = 0; + + foreach ($dnsRecords as $dnsRecord) { + try { + if ($this->option('sync')) { + // Run synchronously + CheckDnsRecord::dispatchSync($dnsRecord); + $this->newLine(); + $this->line(" ✓ Checked: {$dnsRecord->site->name} - {$dnsRecord->record_type} {$dnsRecord->domain}"); + } else { + // Queue the job + CheckDnsRecord::dispatch($dnsRecord); + } + + $dispatched++; + } catch (\Exception $e) { + $errors++; + $this->newLine(); + $this->error(" ✗ Error checking {$dnsRecord->domain}: {$e->getMessage()}"); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + // Summary + $this->info("DNS monitoring completed!"); + $this->table( + ['Metric', 'Count'], + [ + ['Total DNS Records', $dnsRecords->count()], + ['Successfully Dispatched', $dispatched], + ['Errors', $errors], + ] + ); + + if (!$this->option('sync')) { + $this->comment('Jobs have been queued. Run the queue worker to process them.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MonitorHealth.php b/app/Console/Commands/MonitorHealth.php new file mode 100644 index 0000000..3d249f3 --- /dev/null +++ b/app/Console/Commands/MonitorHealth.php @@ -0,0 +1,105 @@ +info('Starting health endpoint monitoring...'); + + $query = HealthCheck::with('site')->where('is_active', true); + + // Filter by specific health check + if ($healthCheckId = $this->option('health-check')) { + $query->where('id', $healthCheckId); + } + + // Filter by site + if ($siteId = $this->option('site')) { + $query->where('site_id', $siteId); + } + + $healthChecks = $query->get(); + + if ($healthChecks->isEmpty()) { + $this->warn('No active health checks found.'); + return self::SUCCESS; + } + + $this->info("Found {$healthChecks->count()} active health check(s)."); + + $bar = $this->output->createProgressBar($healthChecks->count()); + $bar->start(); + + $dispatched = 0; + $errors = 0; + + foreach ($healthChecks as $healthCheck) { + try { + if ($this->option('sync')) { + // Run synchronously + CheckHealthEndpoint::dispatchSync($healthCheck); + $this->newLine(); + $this->line(" ✓ Checked: {$healthCheck->site->name} - {$healthCheck->endpoint}"); + } else { + // Queue the job + CheckHealthEndpoint::dispatch($healthCheck); + } + + $dispatched++; + } catch (\Exception $e) { + $errors++; + $this->newLine(); + $this->error(" ✗ Error checking {$healthCheck->site->name} - {$healthCheck->endpoint}: {$e->getMessage()}"); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + // Summary + $this->info("Health monitoring completed!"); + $this->table( + ['Metric', 'Count'], + [ + ['Total Health Checks', $healthChecks->count()], + ['Successfully Dispatched', $dispatched], + ['Errors', $errors], + ] + ); + + if (!$this->option('sync')) { + $this->comment('Jobs have been queued. Run the queue worker to process them.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MonitorPing.php b/app/Console/Commands/MonitorPing.php new file mode 100644 index 0000000..b79c51e --- /dev/null +++ b/app/Console/Commands/MonitorPing.php @@ -0,0 +1,90 @@ +info('Starting site ping monitoring...'); + + $query = Site::where('monitoring_enabled', true); + + if ($siteId = $this->option('site')) { + $query->where('id', $siteId); + } + + $sites = $query->get(); + + if ($sites->isEmpty()) { + $this->warn('No enabled sites found to monitor.'); + return self::SUCCESS; + } + + $this->info("Found {$sites->count()} site(s) to ping."); + + $progressBar = $this->output->createProgressBar($sites->count()); + $progressBar->start(); + + $dispatched = 0; + $failed = 0; + + foreach ($sites as $site) { + try { + if ($this->option('sync')) { + // Run synchronously for testing/debugging + CheckSitePing::dispatchSync($site); + } else { + // Dispatch to queue + CheckSitePing::dispatch($site); + } + + $dispatched++; + } catch (\Exception $e) { + $failed++; + Log::error('MonitorPing: Failed to dispatch job', [ + 'site_id' => $site->id, + 'error' => $e->getMessage(), + ]); + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $this->info("Successfully dispatched {$dispatched} ping job(s)."); + + if ($failed > 0) { + $this->error("Failed to dispatch {$failed} job(s)."); + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MonitorTcpPorts.php b/app/Console/Commands/MonitorTcpPorts.php new file mode 100644 index 0000000..9533f66 --- /dev/null +++ b/app/Console/Commands/MonitorTcpPorts.php @@ -0,0 +1,97 @@ +info('Starting TCP port monitoring...'); + + $query = TcpPort::with('site')->where('is_active', true); + + if ($tcpPortId = $this->option('tcp-port')) { + $query->where('id', $tcpPortId); + } + + if ($siteId = $this->option('site')) { + $query->where('site_id', $siteId); + } + + $tcpPorts = $query->get(); + + if ($tcpPorts->isEmpty()) { + $this->warn('No active TCP ports found to monitor.'); + return self::SUCCESS; + } + + $this->info("Found {$tcpPorts->count()} TCP port(s) to check."); + + $progressBar = $this->output->createProgressBar($tcpPorts->count()); + $progressBar->start(); + + $dispatched = 0; + $failed = 0; + + foreach ($tcpPorts as $tcpPort) { + try { + if ($this->option('sync')) { + // Run synchronously for testing/debugging + CheckTcpPort::dispatchSync($tcpPort); + } else { + // Dispatch to queue + CheckTcpPort::dispatch($tcpPort); + } + + $dispatched++; + } catch (\Exception $e) { + $failed++; + Log::error('MonitorTcpPorts: Failed to dispatch job', [ + 'tcp_port_id' => $tcpPort->id, + 'site_id' => $tcpPort->site_id, + 'port' => $tcpPort->port, + 'error' => $e->getMessage(), + ]); + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $this->info("Successfully dispatched {$dispatched} TCP port check job(s)."); + + if ($failed > 0) { + $this->error("Failed to dispatch {$failed} job(s)."); + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..54fc53e --- /dev/null +++ b/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,71 @@ +validate([ + 'token_name' => ['required', 'string', 'max:255'], + ]); + + $token = $request->user()->createToken($validated['token_name']); + + return response()->json([ + 'token' => $token->plainTextToken, + 'type' => 'Bearer', + ], 201); + } + + /** + * List all tokens for the authenticated user. + */ + public function tokens(Request $request): JsonResponse + { + $tokens = $request->user()->tokens()->get(['id', 'name', 'last_used_at', 'created_at']); + + return response()->json([ + 'data' => $tokens, + ]); + } + + /** + * Revoke a specific token. + */ + public function revokeToken(Request $request, string $tokenId): JsonResponse + { + $deleted = $request->user()->tokens()->where('id', $tokenId)->delete(); + + if (!$deleted) { + return response()->json([ + 'message' => 'Token not found', + ], 404); + } + + return response()->json([ + 'message' => 'Token revoked successfully', + ]); + } + + /** + * Revoke all tokens for the authenticated user. + */ + public function revokeAllTokens(Request $request): JsonResponse + { + $request->user()->tokens()->delete(); + + return response()->json([ + 'message' => 'All tokens revoked successfully', + ]); + } +} diff --git a/app/Http/Controllers/Api/LinkController.php b/app/Http/Controllers/Api/LinkController.php new file mode 100644 index 0000000..74808fa --- /dev/null +++ b/app/Http/Controllers/Api/LinkController.php @@ -0,0 +1,62 @@ +user_id !== $request->user()->id) { + return response()->json([ + 'message' => 'Unauthorized', + ], 403); + } + + $perPage = $request->input('per_page', 50); + $perPage = min($perPage, 100); // Max 100 per page + + $brokenLinks = $site->brokenLinks() + ->orderBy('last_checked_at', 'desc') + ->paginate($perPage); + + return response()->json($brokenLinks); + } + + /** + * Get broken links statistics for a site. + */ + public function stats(Request $request, Site $site): JsonResponse + { + // Ensure the site belongs to the authenticated user + if ($site->user_id !== $request->user()->id) { + return response()->json([ + 'message' => 'Unauthorized', + ], 403); + } + + $totalBrokenLinks = $site->brokenLinks()->count(); + + $statusCodeBreakdown = $site->brokenLinks() + ->selectRaw('status_code, COUNT(*) as count') + ->groupBy('status_code') + ->orderBy('count', 'desc') + ->get(); + + return response()->json([ + 'data' => [ + 'total_broken_links' => $totalBrokenLinks, + 'status_code_breakdown' => $statusCodeBreakdown, + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/SiteController.php b/app/Http/Controllers/Api/SiteController.php new file mode 100644 index 0000000..6c1b025 --- /dev/null +++ b/app/Http/Controllers/Api/SiteController.php @@ -0,0 +1,46 @@ +user() + ->sites() + ->withCount(['uptimeChecks', 'sslCertificates', 'brokenLinks']) + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json([ + 'data' => $sites, + ]); + } + + /** + * Get a specific site for the authenticated user. + */ + public function show(Request $request, Site $site): JsonResponse + { + // Ensure the site belongs to the authenticated user + if ($site->user_id !== $request->user()->id) { + return response()->json([ + 'message' => 'Unauthorized', + ], 403); + } + + $site->loadCount(['uptimeChecks', 'sslCertificates', 'brokenLinks']); + + return response()->json([ + 'data' => $site, + ]); + } +} diff --git a/app/Http/Controllers/Api/SslController.php b/app/Http/Controllers/Api/SslController.php new file mode 100644 index 0000000..ed914dc --- /dev/null +++ b/app/Http/Controllers/Api/SslController.php @@ -0,0 +1,76 @@ +user_id !== $request->user()->id) { + return response()->json([ + 'message' => 'Unauthorized', + ], 403); + } + + $latestCertificate = $site->sslCertificates() + ->latest('checked_at') + ->first(); + + if (!$latestCertificate) { + return response()->json([ + 'message' => 'No SSL certificate data found', + ], 404); + } + + $daysUntilExpiry = null; + $isExpiring = false; + + if ($latestCertificate->expires_at) { + $daysUntilExpiry = now()->diffInDays($latestCertificate->expires_at, false); + $isExpiring = $daysUntilExpiry <= 30 && $daysUntilExpiry > 0; + } + + return response()->json([ + 'data' => [ + 'issuer' => $latestCertificate->issuer, + 'expires_at' => $latestCertificate->expires_at, + 'checked_at' => $latestCertificate->checked_at, + 'days_until_expiry' => $daysUntilExpiry ? (int) $daysUntilExpiry : null, + 'is_expiring_soon' => $isExpiring, + 'is_expired' => $daysUntilExpiry !== null && $daysUntilExpiry < 0, + ], + ]); + } + + /** + * Get SSL certificate history for a site. + */ + public function history(SslHistoryRequest $request, Site $site): JsonResponse + { + // Ensure the site belongs to the authenticated user + if ($site->user_id !== $request->user()->id) { + return response()->json([ + 'message' => 'Unauthorized', + ], 403); + } + + $perPage = $request->input('per_page', 20); + $perPage = min($perPage, 100); // Max 100 per page + + $certificates = $site->sslCertificates() + ->orderBy('checked_at', 'desc') + ->paginate($perPage); + + return response()->json($certificates); + } +} diff --git a/app/Http/Controllers/Api/UptimeController.php b/app/Http/Controllers/Api/UptimeController.php new file mode 100644 index 0000000..031d45f --- /dev/null +++ b/app/Http/Controllers/Api/UptimeController.php @@ -0,0 +1,73 @@ +user_id !== $request->user()->id) { + return response()->json([ + 'message' => 'Unauthorized', + ], 403); + } + + $perPage = $request->input('per_page', 50); + $perPage = min($perPage, 100); // Max 100 per page + + $uptimeChecks = $site->uptimeChecks() + ->orderBy('checked_at', 'desc') + ->paginate($perPage); + + return response()->json($uptimeChecks); + } + + /** + * Get uptime statistics for a site. + */ + public function stats(Request $request, Site $site): JsonResponse + { + // Ensure the site belongs to the authenticated user + if ($site->user_id !== $request->user()->id) { + return response()->json([ + 'message' => 'Unauthorized', + ], 403); + } + + $totalChecks = $site->uptimeChecks()->count(); + $successfulChecks = $site->uptimeChecks() + ->whereBetween('status_code', [200, 299]) + ->count(); + + $uptimePercentage = $totalChecks > 0 + ? round(($successfulChecks / $totalChecks) * 100, 2) + : 0; + + $averageResponseTime = $site->uptimeChecks() + ->avg('response_time_ms'); + + $latestCheck = $site->uptimeChecks() + ->latest('checked_at') + ->first(); + + return response()->json([ + 'data' => [ + 'total_checks' => $totalChecks, + 'successful_checks' => $successfulChecks, + 'uptime_percentage' => $uptimePercentage, + 'average_response_time_ms' => $averageResponseTime ? round($averageResponseTime, 2) : null, + 'latest_check' => $latestCheck, + ], + ]); + } +} diff --git a/app/Http/Controllers/DnsRecordController.php b/app/Http/Controllers/DnsRecordController.php new file mode 100644 index 0000000..e4b030c --- /dev/null +++ b/app/Http/Controllers/DnsRecordController.php @@ -0,0 +1,173 @@ +user_id !== Auth::id()) { + abort(403, 'Unauthorized access to this site.'); + } + + $dnsRecords = $site->dnsRecords() + ->orderBy('created_at', 'desc') + ->get(); + + return view('dns-records.index', compact('site', 'dnsRecords')); + } + + /** + * Show the form for creating a new DNS record. + */ + public function create(Site $site): View + { + // Ensure the user owns the site + if ($site->user_id !== Auth::id()) { + abort(403, 'Unauthorized access to this site.'); + } + + $recordTypes = DnsRecord::getRecordTypes(); + + return view('dns-records.create', compact('site', 'recordTypes')); + } + + /** + * Store a newly created DNS record in storage. + */ + public function store(Request $request, Site $site): RedirectResponse + { + // Ensure the user owns the site + if ($site->user_id !== Auth::id()) { + abort(403, 'Unauthorized access to this site.'); + } + + $validated = $request->validate([ + 'domain' => ['required', 'string', 'max:255'], + 'record_type' => ['required', 'string', 'in:A,AAAA,CNAME,MX,TXT,NS,SOA,PTR,SRV'], + 'expected_value' => ['nullable', 'string', 'max:1000'], + 'is_active' => ['boolean'], + ]); + + // Check for duplicate record + $exists = $site->dnsRecords() + ->where('domain', $validated['domain']) + ->where('record_type', $validated['record_type']) + ->exists(); + + if ($exists) { + return back() + ->withInput() + ->withErrors(['domain' => "A {$validated['record_type']} record for {$validated['domain']} already exists for this site."]); + } + + $dnsRecord = $site->dnsRecords()->create([ + 'domain' => $validated['domain'], + 'record_type' => $validated['record_type'], + 'expected_value' => $validated['expected_value'] ?? null, + 'is_active' => $validated['is_active'] ?? true, + ]); + + return redirect() + ->route('dns-records.show', [$site, $dnsRecord]) + ->with('success', 'DNS record created successfully.'); + } + + /** + * Display the specified DNS record. + */ + public function show(Site $site, DnsRecord $dnsRecord): View + { + // Ensure the user owns the site and the DNS record belongs to the site + if ($site->user_id !== Auth::id() || $dnsRecord->site_id !== $site->id) { + abort(403, 'Unauthorized access to this DNS record.'); + } + + return view('dns-records.show', compact('site', 'dnsRecord')); + } + + /** + * Show the form for editing the specified DNS record. + */ + public function edit(Site $site, DnsRecord $dnsRecord): View + { + // Ensure the user owns the site and the DNS record belongs to the site + if ($site->user_id !== Auth::id() || $dnsRecord->site_id !== $site->id) { + abort(403, 'Unauthorized access to this DNS record.'); + } + + $recordTypes = DnsRecord::getRecordTypes(); + + return view('dns-records.edit', compact('site', 'dnsRecord', 'recordTypes')); + } + + /** + * Update the specified DNS record in storage. + */ + public function update(Request $request, Site $site, DnsRecord $dnsRecord): RedirectResponse + { + // Ensure the user owns the site and the DNS record belongs to the site + if ($site->user_id !== Auth::id() || $dnsRecord->site_id !== $site->id) { + abort(403, 'Unauthorized access to this DNS record.'); + } + + $validated = $request->validate([ + 'domain' => ['required', 'string', 'max:255'], + 'record_type' => ['required', 'string', 'in:A,AAAA,CNAME,MX,TXT,NS,SOA,PTR,SRV'], + 'expected_value' => ['nullable', 'string', 'max:1000'], + 'is_active' => ['boolean'], + ]); + + // Check for duplicate record (excluding current record) + $exists = $site->dnsRecords() + ->where('domain', $validated['domain']) + ->where('record_type', $validated['record_type']) + ->where('id', '!=', $dnsRecord->id) + ->exists(); + + if ($exists) { + return back() + ->withInput() + ->withErrors(['domain' => "A {$validated['record_type']} record for {$validated['domain']} already exists for this site."]); + } + + $dnsRecord->update([ + 'domain' => $validated['domain'], + 'record_type' => $validated['record_type'], + 'expected_value' => $validated['expected_value'] ?? null, + 'is_active' => $validated['is_active'] ?? $dnsRecord->is_active, + ]); + + return redirect() + ->route('dns-records.show', [$site, $dnsRecord]) + ->with('success', 'DNS record updated successfully.'); + } + + /** + * Remove the specified DNS record from storage. + */ + public function destroy(Site $site, DnsRecord $dnsRecord): RedirectResponse + { + // Ensure the user owns the site and the DNS record belongs to the site + if ($site->user_id !== Auth::id() || $dnsRecord->site_id !== $site->id) { + abort(403, 'Unauthorized access to this DNS record.'); + } + + $dnsRecord->delete(); + + return redirect() + ->route('dns-records.index', $site) + ->with('success', 'DNS record deleted successfully.'); + } +} diff --git a/app/Http/Controllers/HealthCheckController.php b/app/Http/Controllers/HealthCheckController.php new file mode 100644 index 0000000..54cb51d --- /dev/null +++ b/app/Http/Controllers/HealthCheckController.php @@ -0,0 +1,185 @@ +user_id !== Auth::id()) { + abort(403, 'Unauthorized access to this site.'); + } + + $healthChecks = $site->healthChecks() + ->orderBy('created_at', 'desc') + ->get(); + + return view('health-checks.index', compact('site', 'healthChecks')); + } + + /** + * Show the form for creating a new health check. + */ + public function create(Site $site): View + { + // Ensure the user owns the site + if ($site->user_id !== Auth::id()) { + abort(403, 'Unauthorized access to this site.'); + } + + return view('health-checks.create', compact('site')); + } + + /** + * Store a newly created health check in storage. + */ + public function store(Request $request, Site $site): RedirectResponse + { + // Ensure the user owns the site + if ($site->user_id !== Auth::id()) { + abort(403, 'Unauthorized access to this site.'); + } + + $validated = $request->validate([ + 'endpoint' => ['required', 'string', 'max:255'], + 'expected_json_keys' => ['nullable', 'string'], + 'performance_threshold' => ['nullable', 'integer', 'min:1', 'max:60000'], + 'is_active' => ['boolean'], + ]); + + // Convert comma-separated keys to array + $expectedKeys = null; + if (!empty($validated['expected_json_keys'])) { + $expectedKeys = array_map( + 'trim', + explode(',', $validated['expected_json_keys']) + ); + } + + // Check for duplicate endpoint + $exists = $site->healthChecks() + ->where('endpoint', $validated['endpoint']) + ->exists(); + + if ($exists) { + return back() + ->withInput() + ->withErrors(['endpoint' => 'This endpoint is already being monitored for this site.']); + } + + $healthCheck = $site->healthChecks()->create([ + 'endpoint' => $validated['endpoint'], + 'expected_json_keys' => $expectedKeys, + 'performance_threshold' => $validated['performance_threshold'] ?? null, + 'is_active' => $validated['is_active'] ?? true, + ]); + + return redirect() + ->route('health-checks.show', [$site, $healthCheck]) + ->with('success', 'Health check created successfully.'); + } + + /** + * Display the specified health check. + */ + public function show(Site $site, HealthCheck $healthCheck): View + { + // Ensure the user owns the site and the health check belongs to the site + if ($site->user_id !== Auth::id() || $healthCheck->site_id !== $site->id) { + abort(403, 'Unauthorized access to this health check.'); + } + + return view('health-checks.show', compact('site', 'healthCheck')); + } + + /** + * Show the form for editing the specified health check. + */ + public function edit(Site $site, HealthCheck $healthCheck): View + { + // Ensure the user owns the site and the health check belongs to the site + if ($site->user_id !== Auth::id() || $healthCheck->site_id !== $site->id) { + abort(403, 'Unauthorized access to this health check.'); + } + + return view('health-checks.edit', compact('site', 'healthCheck')); + } + + /** + * Update the specified health check in storage. + */ + public function update(Request $request, Site $site, HealthCheck $healthCheck): RedirectResponse + { + // Ensure the user owns the site and the health check belongs to the site + if ($site->user_id !== Auth::id() || $healthCheck->site_id !== $site->id) { + abort(403, 'Unauthorized access to this health check.'); + } + + $validated = $request->validate([ + 'endpoint' => ['required', 'string', 'max:255'], + 'expected_json_keys' => ['nullable', 'string'], + 'performance_threshold' => ['nullable', 'integer', 'min:1', 'max:60000'], + 'is_active' => ['boolean'], + ]); + + // Convert comma-separated keys to array + $expectedKeys = null; + if (!empty($validated['expected_json_keys'])) { + $expectedKeys = array_map( + 'trim', + explode(',', $validated['expected_json_keys']) + ); + } + + // Check for duplicate endpoint (excluding current health check) + $exists = $site->healthChecks() + ->where('endpoint', $validated['endpoint']) + ->where('id', '!=', $healthCheck->id) + ->exists(); + + if ($exists) { + return back() + ->withInput() + ->withErrors(['endpoint' => 'This endpoint is already being monitored for this site.']); + } + + $healthCheck->update([ + 'endpoint' => $validated['endpoint'], + 'expected_json_keys' => $expectedKeys, + 'performance_threshold' => $validated['performance_threshold'] ?? null, + 'is_active' => $validated['is_active'] ?? $healthCheck->is_active, + ]); + + return redirect() + ->route('health-checks.show', [$site, $healthCheck]) + ->with('success', 'Health check updated successfully.'); + } + + /** + * Remove the specified health check from storage. + */ + public function destroy(Site $site, HealthCheck $healthCheck): RedirectResponse + { + // Ensure the user owns the site and the health check belongs to the site + if ($site->user_id !== Auth::id() || $healthCheck->site_id !== $site->id) { + abort(403, 'Unauthorized access to this health check.'); + } + + $healthCheck->delete(); + + return redirect() + ->route('health-checks.index', $site) + ->with('success', 'Health check deleted successfully.'); + } +} diff --git a/app/Http/Controllers/TcpPortController.php b/app/Http/Controllers/TcpPortController.php new file mode 100644 index 0000000..4f15dde --- /dev/null +++ b/app/Http/Controllers/TcpPortController.php @@ -0,0 +1,165 @@ +user_id !== Auth::id()) { + abort(403); + } + + $tcpPorts = $site->tcpPorts() + ->orderBy('port') + ->paginate(20); + + return view('tcp-ports.index', compact('site', 'tcpPorts')); + } + + /** + * Show the form for creating a new resource. + */ + public function create(Site $site) + { + // Ensure the site belongs to the authenticated user + if ($site->user_id !== Auth::id()) { + abort(403); + } + + $commonPorts = TcpPort::getCommonPorts(); + + return view('tcp-ports.create', compact('site', 'commonPorts')); + } + + /** + * Store a newly created resource in storage. + */ + public function store(Request $request, Site $site) + { + // Ensure the site belongs to the authenticated user + if ($site->user_id !== Auth::id()) { + abort(403); + } + + $validated = $request->validate([ + 'port' => ['required', 'integer', 'min:1', 'max:65535'], + 'name' => ['nullable', 'string', 'max:255'], + 'is_active' => ['boolean'], + ]); + + // Check if port already exists for this site + $exists = $site->tcpPorts() + ->where('port', $validated['port']) + ->exists(); + + if ($exists) { + return back() + ->withInput() + ->withErrors(['port' => 'This port is already being monitored for this site.']); + } + + $site->tcpPorts()->create([ + 'port' => $validated['port'], + 'name' => $validated['name'] ?? null, + 'is_active' => $validated['is_active'] ?? true, + ]); + + return redirect() + ->route('tcp-ports.index', $site) + ->with('success', 'TCP port added successfully.'); + } + + /** + * Display the specified resource. + */ + public function show(Site $site, TcpPort $tcpPort) + { + // Ensure the port belongs to the site and user + if ($tcpPort->site_id !== $site->id || $site->user_id !== Auth::id()) { + abort(403); + } + + return view('tcp-ports.show', compact('site', 'tcpPort')); + } + + /** + * Show the form for editing the specified resource. + */ + public function edit(Site $site, TcpPort $tcpPort) + { + // Ensure the port belongs to the site and user + if ($tcpPort->site_id !== $site->id || $site->user_id !== Auth::id()) { + abort(403); + } + + $commonPorts = TcpPort::getCommonPorts(); + + return view('tcp-ports.edit', compact('site', 'tcpPort', 'commonPorts')); + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, Site $site, TcpPort $tcpPort) + { + // Ensure the port belongs to the site and user + if ($tcpPort->site_id !== $site->id || $site->user_id !== Auth::id()) { + abort(403); + } + + $validated = $request->validate([ + 'port' => ['required', 'integer', 'min:1', 'max:65535'], + 'name' => ['nullable', 'string', 'max:255'], + 'is_active' => ['boolean'], + ]); + + // Check if port already exists for this site (excluding current) + $exists = $site->tcpPorts() + ->where('port', $validated['port']) + ->where('id', '!=', $tcpPort->id) + ->exists(); + + if ($exists) { + return back() + ->withInput() + ->withErrors(['port' => 'This port is already being monitored for this site.']); + } + + $tcpPort->update([ + 'port' => $validated['port'], + 'name' => $validated['name'] ?? null, + 'is_active' => $validated['is_active'] ?? $tcpPort->is_active, + ]); + + return redirect() + ->route('tcp-ports.index', $site) + ->with('success', 'TCP port updated successfully.'); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Site $site, TcpPort $tcpPort) + { + // Ensure the port belongs to the site and user + if ($tcpPort->site_id !== $site->id || $site->user_id !== Auth::id()) { + abort(403); + } + + $tcpPort->delete(); + + return redirect() + ->route('tcp-ports.index', $site) + ->with('success', 'TCP port deleted successfully.'); + } +} diff --git a/app/Http/Requests/Api/BrokenLinksIndexRequest.php b/app/Http/Requests/Api/BrokenLinksIndexRequest.php new file mode 100644 index 0000000..b1fd828 --- /dev/null +++ b/app/Http/Requests/Api/BrokenLinksIndexRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/Api/SslHistoryRequest.php b/app/Http/Requests/Api/SslHistoryRequest.php new file mode 100644 index 0000000..ca9e847 --- /dev/null +++ b/app/Http/Requests/Api/SslHistoryRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/Api/UptimeIndexRequest.php b/app/Http/Requests/Api/UptimeIndexRequest.php new file mode 100644 index 0000000..00b6aa1 --- /dev/null +++ b/app/Http/Requests/Api/UptimeIndexRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Jobs/CheckDnsRecord.php b/app/Jobs/CheckDnsRecord.php new file mode 100644 index 0000000..b750860 --- /dev/null +++ b/app/Jobs/CheckDnsRecord.php @@ -0,0 +1,267 @@ + $this->dnsRecord->id, + 'domain' => $this->dnsRecord->domain, + 'record_type' => $this->dnsRecord->record_type, + ]); + + // Perform DNS lookup + $result = $this->performDnsLookup(); + + $wasValid = $this->dnsRecord->is_valid; + $isValid = $result['is_valid']; + $actualValue = $result['actual_value']; + $errorMessage = $result['error_message']; + + // Update DNS record + $this->dnsRecord->update([ + 'last_checked_at' => now(), + 'is_valid' => $isValid, + 'actual_value' => $actualValue, + 'error_message' => $errorMessage, + ]); + + Log::info('CheckDnsRecord: Check completed', [ + 'dns_record_id' => $this->dnsRecord->id, + 'is_valid' => $isValid, + 'actual_value' => $actualValue, + ]); + + // Send notification if status changed from valid to invalid + if ($wasValid === true && $isValid === false) { + $this->dnsRecord->site->user->notify(new DnsRecordAlert( + $this->dnsRecord, + 'invalid', + $actualValue, + $errorMessage + )); + + Log::warning('CheckDnsRecord: Invalid alert sent', [ + 'dns_record_id' => $this->dnsRecord->id, + 'error_message' => $errorMessage, + ]); + } + + // Send notification when DNS is restored + if ($wasValid === false && $isValid === true) { + $this->dnsRecord->site->user->notify(new DnsRecordAlert( + $this->dnsRecord, + 'valid', + $actualValue + )); + + Log::info('CheckDnsRecord: Valid alert sent', [ + 'dns_record_id' => $this->dnsRecord->id, + ]); + } + + } catch (\Exception $e) { + Log::error('CheckDnsRecord: Unexpected error', [ + 'dns_record_id' => $this->dnsRecord->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->dnsRecord->update([ + 'last_checked_at' => now(), + 'is_valid' => false, + 'error_message' => 'Unexpected error: ' . $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Perform DNS lookup and validation. + */ + protected function performDnsLookup(): array + { + try { + // Map record type to DNS_* constant + $dnsType = $this->getDnsTypeConstant($this->dnsRecord->record_type); + + if ($dnsType === null) { + return [ + 'is_valid' => false, + 'actual_value' => null, + 'error_message' => "Unsupported DNS record type: {$this->dnsRecord->record_type}", + ]; + } + + // Perform DNS lookup + $records = @dns_get_record($this->dnsRecord->domain, $dnsType); + + if ($records === false) { + $error = error_get_last(); + return [ + 'is_valid' => false, + 'actual_value' => null, + 'error_message' => 'DNS lookup failed: ' . ($error['message'] ?? 'Unknown error'), + ]; + } + + if (empty($records)) { + return [ + 'is_valid' => false, + 'actual_value' => null, + 'error_message' => "No {$this->dnsRecord->record_type} records found for {$this->dnsRecord->domain}", + ]; + } + + // Extract values based on record type + $actualValues = $this->extractDnsValues($records, $this->dnsRecord->record_type); + + // If no expected value, just return that records exist + if (empty($this->dnsRecord->expected_value)) { + return [ + 'is_valid' => true, + 'actual_value' => $actualValues, + 'error_message' => null, + ]; + } + + // Validate against expected value + $isValid = $this->validateDnsValue($actualValues, $this->dnsRecord->expected_value); + + return [ + 'is_valid' => $isValid, + 'actual_value' => $actualValues, + 'error_message' => $isValid ? null : 'DNS record does not match expected value', + ]; + + } catch (\Exception $e) { + return [ + 'is_valid' => false, + 'actual_value' => null, + 'error_message' => 'DNS lookup error: ' . $e->getMessage(), + ]; + } + } + + /** + * Get DNS type constant for dns_get_record(). + */ + protected function getDnsTypeConstant(string $recordType): ?int + { + return match (strtoupper($recordType)) { + 'A' => DNS_A, + 'AAAA' => DNS_AAAA, + 'CNAME' => DNS_CNAME, + 'MX' => DNS_MX, + 'TXT' => DNS_TXT, + 'NS' => DNS_NS, + 'SOA' => DNS_SOA, + 'PTR' => DNS_PTR, + 'SRV' => DNS_SRV, + default => null, + }; + } + + /** + * Extract DNS values from records. + */ + protected function extractDnsValues(array $records, string $recordType): array + { + $values = []; + + foreach ($records as $record) { + $value = match (strtoupper($recordType)) { + 'A' => $record['ip'] ?? null, + 'AAAA' => $record['ipv6'] ?? null, + 'CNAME' => $record['target'] ?? null, + 'MX' => isset($record['target']) ? "{$record['pri']} {$record['target']}" : null, + 'TXT' => $record['txt'] ?? null, + 'NS' => $record['target'] ?? null, + 'SOA' => isset($record['mname']) ? $record['mname'] : null, + 'PTR' => $record['target'] ?? null, + 'SRV' => isset($record['target']) ? "{$record['pri']} {$record['weight']} {$record['port']} {$record['target']}" : null, + default => null, + }; + + if ($value !== null) { + $values[] = $value; + } + } + + return $values; + } + + /** + * Validate DNS value against expected value. + */ + protected function validateDnsValue(array $actualValues, string $expectedValue): bool + { + // Normalize expected value + $expectedValue = trim($expectedValue); + + // Check if any actual value matches expected + foreach ($actualValues as $actualValue) { + // Exact match + if (strcasecmp($actualValue, $expectedValue) === 0) { + return true; + } + + // Partial match for MX and SRV records (check target only) + if (stripos($actualValue, $expectedValue) !== false) { + return true; + } + } + + return false; + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('CheckDnsRecord: Job failed after all retries', [ + 'dns_record_id' => $this->dnsRecord->id, + 'exception' => $exception->getMessage(), + ]); + } +} diff --git a/app/Jobs/CheckHealthEndpoint.php b/app/Jobs/CheckHealthEndpoint.php new file mode 100644 index 0000000..637d4ac --- /dev/null +++ b/app/Jobs/CheckHealthEndpoint.php @@ -0,0 +1,255 @@ + 10, + 'connect_timeout' => 5, + 'allow_redirects' => [ + 'max' => 3, + 'track_redirects' => true, + ], + 'http_errors' => false, + ]); + + $url = $this->healthCheck->full_url; + $startTime = microtime(true); + + Log::info('CheckHealthEndpoint: Starting check', [ + 'health_check_id' => $this->healthCheck->id, + 'url' => $url, + ]); + + $response = $client->get($url, [ + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'IsMySiteUp Health Monitor/1.0', + ], + ]); + + $endTime = microtime(true); + $responseTime = (int) round(($endTime - $startTime) * 1000); + $statusCode = $response->getStatusCode(); + $body = (string) $response->getBody(); + + // Try to decode JSON + $responseData = null; + $isValidJson = false; + + try { + $responseData = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + $isValidJson = true; + } catch (\JsonException $e) { + Log::warning('CheckHealthEndpoint: Invalid JSON response', [ + 'health_check_id' => $this->healthCheck->id, + 'error' => $e->getMessage(), + 'body_preview' => substr($body, 0, 500), + ]); + } + + // Determine if endpoint is healthy + $isHealthy = $this->determineHealth($statusCode, $isValidJson, $responseData); + + $wasHealthy = $this->healthCheck->is_healthy; + + // Update health check record + $this->healthCheck->update([ + 'last_checked_at' => now(), + 'is_healthy' => $isHealthy, + 'response_data' => $responseData, + 'response_time_ms' => $responseTime, + 'status_code' => $statusCode, + ]); + + Log::info('CheckHealthEndpoint: Check completed', [ + 'health_check_id' => $this->healthCheck->id, + 'is_healthy' => $isHealthy, + 'status_code' => $statusCode, + 'response_time_ms' => $responseTime, + ]); + + // Send notification if health status changed from healthy to unhealthy + if ($wasHealthy === true && $isHealthy === false) { + $this->healthCheck->site->user->notify(new HealthCheckAlert( + $this->healthCheck, + 'unhealthy', + $statusCode, + $responseTime + )); + + Log::warning('CheckHealthEndpoint: Unhealthy alert sent', [ + 'health_check_id' => $this->healthCheck->id, + 'status_code' => $statusCode, + ]); + } + + // Optionally send notification when health is restored + if ($wasHealthy === false && $isHealthy === true) { + $this->healthCheck->site->user->notify(new HealthCheckAlert( + $this->healthCheck, + 'healthy', + $statusCode, + $responseTime + )); + + Log::info('CheckHealthEndpoint: Healthy alert sent', [ + 'health_check_id' => $this->healthCheck->id, + ]); + } + + // Check performance threshold and send alert if exceeded + if ($this->healthCheck->performance_threshold && $responseTime > $this->healthCheck->performance_threshold) { + $this->healthCheck->site->user->notify(new PerformanceAlert( + resourceType: 'Health Check', + resourceName: $this->healthCheck->endpoint, + responseTime: $responseTime, + threshold: $this->healthCheck->performance_threshold, + url: $this->healthCheck->full_url, + resourceId: $this->healthCheck->site_id + )); + + Log::warning('CheckHealthEndpoint: Performance threshold exceeded', [ + 'health_check_id' => $this->healthCheck->id, + 'response_time_ms' => $responseTime, + 'threshold_ms' => $this->healthCheck->performance_threshold, + ]); + } + + } catch (ConnectException $e) { + // Connection timeout or DNS failure + Log::error('CheckHealthEndpoint: Connection failed', [ + 'health_check_id' => $this->healthCheck->id, + 'error' => $e->getMessage(), + ]); + + $this->healthCheck->update([ + 'last_checked_at' => now(), + 'is_healthy' => false, + 'response_data' => ['error' => 'Connection failed: ' . $e->getMessage()], + 'response_time_ms' => null, + 'status_code' => null, + ]); + + } catch (RequestException $e) { + // HTTP error occurred + $statusCode = $e->getResponse() ? $e->getResponse()->getStatusCode() : null; + + Log::error('CheckHealthEndpoint: Request failed', [ + 'health_check_id' => $this->healthCheck->id, + 'status_code' => $statusCode, + 'error' => $e->getMessage(), + ]); + + $this->healthCheck->update([ + 'last_checked_at' => now(), + 'is_healthy' => false, + 'response_data' => ['error' => 'Request failed: ' . $e->getMessage()], + 'response_time_ms' => null, + 'status_code' => $statusCode, + ]); + + } catch (\Exception $e) { + // Any other exception + Log::error('CheckHealthEndpoint: Unexpected error', [ + 'health_check_id' => $this->healthCheck->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->healthCheck->update([ + 'last_checked_at' => now(), + 'is_healthy' => false, + 'response_data' => ['error' => 'Unexpected error: ' . $e->getMessage()], + 'response_time_ms' => null, + 'status_code' => null, + ]); + + throw $e; + } + } + + /** + * Determine if the endpoint is healthy based on response. + */ + protected function determineHealth(int $statusCode, bool $isValidJson, ?array $responseData): bool + { + // Check if status code is successful (2xx) + if ($statusCode < 200 || $statusCode >= 300) { + return false; + } + + // Check if response is valid JSON + if (!$isValidJson) { + return false; + } + + // Validate expected JSON keys if configured + if (!empty($this->healthCheck->expected_json_keys)) { + if (!$this->healthCheck->validateResponseKeys($responseData ?? [])) { + Log::warning('CheckHealthEndpoint: Missing expected JSON keys', [ + 'health_check_id' => $this->healthCheck->id, + 'expected_keys' => $this->healthCheck->expected_json_keys, + 'response_keys' => array_keys($responseData ?? []), + ]); + return false; + } + } + + return true; + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('CheckHealthEndpoint: Job failed after all retries', [ + 'health_check_id' => $this->healthCheck->id, + 'exception' => $exception->getMessage(), + ]); + } +} diff --git a/app/Jobs/CheckSitePing.php b/app/Jobs/CheckSitePing.php new file mode 100644 index 0000000..3824158 --- /dev/null +++ b/app/Jobs/CheckSitePing.php @@ -0,0 +1,261 @@ +extractHost($this->site->url); + + if (!$host) { + Log::error('CheckSitePing: Invalid URL', [ + 'site_id' => $this->site->id, + 'url' => $this->site->url, + ]); + + // Record failed check due to invalid URL + UptimeCheck::create([ + 'site_id' => $this->site->id, + 'check_type' => 'ping', + 'status_code' => null, + 'response_time_ms' => null, + 'is_online' => false, + 'checked_at' => now(), + ]); + return; + } + + $result = $this->ping($host); + + Log::info('CheckSitePing: Raw result', [ + 'site_id' => $this->site->id, + 'host' => $host, + 'result' => $result, + ]); + + UptimeCheck::create([ + 'site_id' => $this->site->id, + 'check_type' => 'ping', + 'status_code' => null, // ICMP doesn't have HTTP status codes + 'response_time_ms' => $result['avg_time'], + 'is_online' => $result['is_online'], + 'checked_at' => now(), + ]); + + Log::info('CheckSitePing: Ping completed', [ + 'site_id' => $this->site->id, + 'host' => $host, + 'is_online' => $result['is_online'], + 'avg_time' => $result['avg_time'], + ]); + + } catch (\Exception $e) { + Log::error('CheckSitePing: Failed', [ + 'site_id' => $this->site->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Record failed check + UptimeCheck::create([ + 'site_id' => $this->site->id, + 'check_type' => 'ping', + 'status_code' => null, + 'response_time_ms' => null, + 'is_online' => false, + 'checked_at' => now(), + ]); + + throw $e; + } + } + + /** + * Extract host from URL. + */ + protected function extractHost(string $url): ?string + { + $parsed = parse_url($url); + return $parsed['host'] ?? null; + } + + /** + * Execute ping command and parse results. + */ + protected function ping(string $host): array + { + $isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + + // Check if ping command exists + $pingPath = $this->findPingCommand($isWindows); + + if (!$pingPath) { + // Ping command not found, use TCP connection as fallback + Log::warning('CheckSitePing: ping command not found, using TCP fallback', [ + 'site_id' => $this->site->id, + 'host' => $host, + ]); + + return $this->tcpCheck($host); + } + + // Build ping command based on OS + if ($isWindows) { + $command = sprintf('%s -n 4 %s 2>&1', $pingPath, escapeshellarg($host)); + } else { + $command = sprintf('%s -c 4 -W 5 %s 2>&1', $pingPath, escapeshellarg($host)); + } + + $output = []; + $returnVar = 0; + + exec($command, $output, $returnVar); + + $isOnline = $returnVar === 0; + $avgTime = null; + + // Parse average response time + if ($isOnline) { + $avgTime = $this->parseAverageTime($output, $isWindows); + } + + return [ + 'is_online' => $isOnline, + 'avg_time' => $avgTime, + 'output' => implode("\n", $output), + 'return_code' => $returnVar, + 'command' => $command, + ]; + } + + /** + * Find ping command path. + */ + protected function findPingCommand(bool $isWindows): ?string + { + if ($isWindows) { + return 'ping'; + } + + // Check common Linux locations + $locations = ['/bin/ping', '/usr/bin/ping', '/sbin/ping', '/usr/sbin/ping']; + + foreach ($locations as $location) { + if (file_exists($location) && is_executable($location)) { + return $location; + } + } + + // Try to find using which + exec('which ping 2>&1', $whichOutput, $whichReturn); + if ($whichReturn === 0 && !empty($whichOutput[0])) { + return trim($whichOutput[0]); + } + + return null; + } + + /** + * TCP connection check as fallback when ping is not available. + */ + protected function tcpCheck(string $host): array + { + $port = 80; + $timeout = 5; + $startTime = microtime(true); + + $fp = @fsockopen($host, $port, $errno, $errstr, $timeout); + + $endTime = microtime(true); + $responseTime = (int) round(($endTime - $startTime) * 1000); + + $isOnline = $fp !== false; + + if ($fp) { + fclose($fp); + } + + return [ + 'is_online' => $isOnline, + 'avg_time' => $isOnline ? $responseTime : null, + 'output' => $isOnline ? "TCP connection successful on port {$port}" : "TCP connection failed: {$errstr}", + 'return_code' => $isOnline ? 0 : $errno, + 'command' => "fsockopen({$host}, {$port}, {$timeout}s)", + ]; + } + + /** + * Parse average response time from ping output. + */ + protected function parseAverageTime(array $output, bool $isWindows): ?int + { + $outputText = implode("\n", $output); + + if ($isWindows) { + // Windows format: "Average = 23ms" + if (preg_match('/Average\s*=\s*(\d+)ms/i', $outputText, $matches)) { + return (int) $matches[1]; + } + } else { + // Linux format: "rtt min/avg/max/mdev = 12.345/23.456/34.567/8.901 ms" + if (preg_match('/rtt min\/avg\/max\/mdev = [\d.]+\/([\d.]+)\/[\d.]+\/[\d.]+ ms/i', $outputText, $matches)) { + return (int) round((float) $matches[1]); + } + + // Alternative Linux format: "round-trip min/avg/max = 12.345/23.456/34.567 ms" + if (preg_match('/round-trip min\/avg\/max = [\d.]+\/([\d.]+)\/[\d.]+ ms/i', $outputText, $matches)) { + return (int) round((float) $matches[1]); + } + } + + return null; + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('CheckSitePing: Job failed after all retries', [ + 'site_id' => $this->site->id, + 'exception' => $exception->getMessage(), + ]); + } +} diff --git a/app/Jobs/CheckTcpPort.php b/app/Jobs/CheckTcpPort.php new file mode 100644 index 0000000..3152521 --- /dev/null +++ b/app/Jobs/CheckTcpPort.php @@ -0,0 +1,185 @@ +extractHost($this->tcpPort->site->url); + + if (!$host) { + Log::error('CheckTcpPort: Invalid URL', [ + 'tcp_port_id' => $this->tcpPort->id, + 'site_id' => $this->tcpPort->site_id, + 'url' => $this->tcpPort->site->url, + ]); + + $this->tcpPort->update([ + 'is_open' => false, + 'response_time_ms' => null, + 'last_checked_at' => now(), + ]); + + return; + } + + $result = $this->checkPort($host, $this->tcpPort->port); + + $wasOpen = $this->tcpPort->is_open; + + $this->tcpPort->update([ + 'is_open' => $result['is_open'], + 'response_time_ms' => $result['response_time'], + 'last_checked_at' => now(), + ]); + + Log::info('CheckTcpPort: Check completed', [ + 'tcp_port_id' => $this->tcpPort->id, + 'site_id' => $this->tcpPort->site_id, + 'host' => $host, + 'port' => $this->tcpPort->port, + 'is_open' => $result['is_open'], + 'response_time_ms' => $result['response_time'], + ]); + + // Send notification if port status changed from open to closed + if ($wasOpen === true && $result['is_open'] === false) { + $this->tcpPort->site->user->notify(new TcpPortAlert( + $this->tcpPort, + 'closed' + )); + + Log::warning('CheckTcpPort: Port closed alert sent', [ + 'tcp_port_id' => $this->tcpPort->id, + 'port' => $this->tcpPort->port, + ]); + } + + // Optionally send notification if port opened + if ($wasOpen === false && $result['is_open'] === true) { + $this->tcpPort->site->user->notify(new TcpPortAlert( + $this->tcpPort, + 'opened' + )); + + Log::info('CheckTcpPort: Port opened alert sent', [ + 'tcp_port_id' => $this->tcpPort->id, + 'port' => $this->tcpPort->port, + ]); + } + + } catch (\Exception $e) { + Log::error('CheckTcpPort: Failed', [ + 'tcp_port_id' => $this->tcpPort->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $this->tcpPort->update([ + 'is_open' => false, + 'response_time_ms' => null, + 'last_checked_at' => now(), + ]); + + throw $e; + } + } + + /** + * Extract host from URL. + */ + protected function extractHost(string $url): ?string + { + $parsed = parse_url($url); + return $parsed['host'] ?? null; + } + + /** + * Check if a TCP port is open. + */ + protected function checkPort(string $host, int $port): array + { + $timeout = 5; + $startTime = microtime(true); + + $errno = 0; + $errstr = ''; + + // Suppress warnings and capture errors + $fp = @fsockopen($host, $port, $errno, $errstr, $timeout); + + $endTime = microtime(true); + $responseTime = (int) round(($endTime - $startTime) * 1000); + + $isOpen = $fp !== false; + + if ($fp) { + fclose($fp); + } + + if (!$isOpen) { + Log::debug('CheckTcpPort: Port check failed', [ + 'host' => $host, + 'port' => $port, + 'errno' => $errno, + 'errstr' => $errstr, + 'response_time_ms' => $responseTime, + ]); + } + + return [ + 'is_open' => $isOpen, + 'response_time' => $isOpen ? $responseTime : null, + 'error_code' => $errno, + 'error_message' => $errstr, + ]; + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('CheckTcpPort: Job failed after all retries', [ + 'tcp_port_id' => $this->tcpPort->id, + 'exception' => $exception->getMessage(), + ]); + } +} diff --git a/app/Jobs/CheckUptime.php b/app/Jobs/CheckUptime.php index ecb0e93..1f1257e 100644 --- a/app/Jobs/CheckUptime.php +++ b/app/Jobs/CheckUptime.php @@ -87,8 +87,10 @@ class CheckUptime implements ShouldQueue // Store the uptime check result UptimeCheck::create([ 'site_id' => $this->site->id, + 'check_type' => 'http', 'status_code' => $statusCode, 'response_time_ms' => $responseTime, + 'is_online' => $statusCode !== null && $statusCode < 400, 'checked_at' => now(), ]); diff --git a/app/Models/DnsRecord.php b/app/Models/DnsRecord.php new file mode 100644 index 0000000..cef6877 --- /dev/null +++ b/app/Models/DnsRecord.php @@ -0,0 +1,129 @@ + 'datetime', + 'is_valid' => 'boolean', + 'is_active' => 'boolean', + 'actual_value' => 'array', + ]; + + /** + * Available DNS record types. + */ + public static function getRecordTypes(): array + { + return [ + 'A' => 'A (IPv4 Address)', + 'AAAA' => 'AAAA (IPv6 Address)', + 'CNAME' => 'CNAME (Canonical Name)', + 'MX' => 'MX (Mail Exchange)', + 'TXT' => 'TXT (Text Record)', + 'NS' => 'NS (Name Server)', + 'SOA' => 'SOA (Start of Authority)', + 'PTR' => 'PTR (Pointer)', + 'SRV' => 'SRV (Service)', + ]; + } + + /** + * Get the site that owns the DNS record. + */ + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + /** + * Scope to filter active DNS records. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope to filter valid records. + */ + public function scopeValid($query) + { + return $query->where('is_valid', true); + } + + /** + * Scope to filter invalid records. + */ + public function scopeInvalid($query) + { + return $query->where('is_valid', false); + } + + /** + * Get status badge color. + */ + public function getStatusColorAttribute(): string + { + if ($this->is_valid === null) { + return 'secondary'; + } + + return $this->is_valid ? 'success' : 'danger'; + } + + /** + * Get status text. + */ + public function getStatusTextAttribute(): string + { + if ($this->is_valid === null) { + return 'Unknown'; + } + + return $this->is_valid ? 'Valid' : 'Invalid'; + } + + /** + * Get display name for the record. + */ + public function getDisplayNameAttribute(): string + { + return "{$this->record_type} - {$this->domain}"; + } + + /** + * Get formatted actual value for display. + */ + public function getFormattedActualValueAttribute(): ?string + { + if (!$this->actual_value) { + return null; + } + + if (is_array($this->actual_value)) { + if (count($this->actual_value) === 1) { + return $this->actual_value[0]; + } + return implode(', ', $this->actual_value); + } + + return $this->actual_value; + } +} diff --git a/app/Models/HealthCheck.php b/app/Models/HealthCheck.php new file mode 100644 index 0000000..afe2c14 --- /dev/null +++ b/app/Models/HealthCheck.php @@ -0,0 +1,143 @@ + 'array', + 'response_data' => 'array', + 'last_checked_at' => 'datetime', + 'is_healthy' => 'boolean', + 'is_active' => 'boolean', + ]; + + /** + * Get the site that owns the health check. + */ + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + /** + * Scope to filter active health checks. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope to filter healthy checks. + */ + public function scopeHealthy($query) + { + return $query->where('is_healthy', true); + } + + /** + * Scope to filter unhealthy checks. + */ + public function scopeUnhealthy($query) + { + return $query->where('is_healthy', false); + } + + /** + * Get the full URL for the health check endpoint. + */ + public function getFullUrlAttribute(): string + { + $siteUrl = rtrim($this->site->url, '/'); + $endpoint = ltrim($this->endpoint, '/'); + + return $siteUrl . '/' . $endpoint; + } + + /** + * Get status badge color. + */ + public function getStatusColorAttribute(): string + { + if ($this->is_healthy === null) { + return 'secondary'; + } + + return $this->is_healthy ? 'success' : 'danger'; + } + + /** + * Get status text. + */ + public function getStatusTextAttribute(): string + { + if ($this->is_healthy === null) { + return 'Unknown'; + } + + return $this->is_healthy ? 'Healthy' : 'Unhealthy'; + } + + /** + * Get display name for the endpoint. + */ + public function getDisplayNameAttribute(): string + { + return $this->endpoint ?: 'Health Check'; + } + + /** + * Validate if response contains all expected keys. + */ + public function validateResponseKeys(array $responseData): bool + { + if (empty($this->expected_json_keys)) { + return true; + } + + foreach ($this->expected_json_keys as $key) { + if (!$this->hasNestedKey($responseData, $key)) { + return false; + } + } + + return true; + } + + /** + * Check if nested key exists in array using dot notation. + */ + protected function hasNestedKey(array $array, string $key): bool + { + if (array_key_exists($key, $array)) { + return true; + } + + foreach (explode('.', $key) as $segment) { + if (!is_array($array) || !array_key_exists($segment, $array)) { + return false; + } + + $array = $array[$segment]; + } + + return true; + } +} diff --git a/app/Models/Site.php b/app/Models/Site.php index 1671af3..c023c38 100644 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -38,4 +38,19 @@ class Site extends Model { return $this->hasMany(BrokenLink::class); } + + public function tcpPorts(): HasMany + { + return $this->hasMany(TcpPort::class); + } + + public function healthChecks(): HasMany + { + return $this->hasMany(HealthCheck::class); + } + + public function dnsRecords(): HasMany + { + return $this->hasMany(DnsRecord::class); + } } diff --git a/app/Models/TcpPort.php b/app/Models/TcpPort.php new file mode 100644 index 0000000..e08a90d --- /dev/null +++ b/app/Models/TcpPort.php @@ -0,0 +1,123 @@ + 'boolean', + 'is_open' => 'boolean', + 'last_checked_at' => 'datetime', + ]; + + /** + * Get the site that owns the TCP port. + */ + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + /** + * Scope to filter active ports. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope to filter open ports. + */ + public function scopeOpen($query) + { + return $query->where('is_open', true); + } + + /** + * Scope to filter closed ports. + */ + public function scopeClosed($query) + { + return $query->where('is_open', false); + } + + /** + * Get common port names. + */ + public static function getCommonPorts(): array + { + return [ + 21 => 'FTP', + 22 => 'SSH', + 23 => 'Telnet', + 25 => 'SMTP', + 53 => 'DNS', + 80 => 'HTTP', + 110 => 'POP3', + 143 => 'IMAP', + 443 => 'HTTPS', + 465 => 'SMTPS', + 587 => 'SMTP (Submission)', + 993 => 'IMAPS', + 995 => 'POP3S', + 3306 => 'MySQL', + 3389 => 'RDP', + 5432 => 'PostgreSQL', + 6379 => 'Redis', + 8080 => 'HTTP Alternate', + 8443 => 'HTTPS Alternate', + 27017 => 'MongoDB', + ]; + } + + /** + * Get display name for the port. + */ + public function getDisplayNameAttribute(): string + { + if ($this->name) { + return $this->name; + } + + $commonPorts = self::getCommonPorts(); + return $commonPorts[$this->port] ?? "Port {$this->port}"; + } + + /** + * Get status badge color. + */ + public function getStatusColorAttribute(): string + { + if ($this->is_open === null) { + return 'secondary'; + } + + return $this->is_open ? 'success' : 'danger'; + } + + /** + * Get status text. + */ + public function getStatusTextAttribute(): string + { + if ($this->is_open === null) { + return 'Unknown'; + } + + return $this->is_open ? 'Open' : 'Closed'; + } +} diff --git a/app/Models/UptimeCheck.php b/app/Models/UptimeCheck.php index 0c0cbed..c691285 100644 --- a/app/Models/UptimeCheck.php +++ b/app/Models/UptimeCheck.php @@ -11,15 +11,42 @@ class UptimeCheck extends Model protected $fillable = [ 'site_id', + 'check_type', 'status_code', 'response_time_ms', + 'is_online', 'checked_at', ]; protected $casts = [ 'checked_at' => 'datetime', + 'is_online' => 'boolean', ]; + /** + * Scope to filter by check type. + */ + public function scopeOfType($query, string $type) + { + return $query->where('check_type', $type); + } + + /** + * Scope to filter HTTP checks. + */ + public function scopeHttpChecks($query) + { + return $query->where('check_type', 'http'); + } + + /** + * Scope to filter ping checks. + */ + public function scopePingChecks($query) + { + return $query->where('check_type', 'ping'); + } + public function site(): BelongsTo { return $this->belongsTo(Site::class); diff --git a/app/Models/User.php b/app/Models/User.php index e043a12..39d227d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,11 +7,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasApiTokens; /** * The attributes that are mass assignable. diff --git a/app/Notifications/DnsRecordAlert.php b/app/Notifications/DnsRecordAlert.php new file mode 100644 index 0000000..ad8540b --- /dev/null +++ b/app/Notifications/DnsRecordAlert.php @@ -0,0 +1,99 @@ + + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $subject = $this->status === 'invalid' + ? "DNS Record Invalid: {$this->dnsRecord->domain}" + : "DNS Record Restored: {$this->dnsRecord->domain}"; + + $message = (new MailMessage) + ->subject($subject); + + if ($this->status === 'invalid') { + $message->error() + ->line("DNS record validation failed for {$this->dnsRecord->record_type} record of {$this->dnsRecord->domain}") + ->line("This could indicate a DNS configuration issue or propagation delay."); + + if ($this->errorMessage) { + $message->line("Error: {$this->errorMessage}"); + } elseif ($this->actualValue && $this->dnsRecord->expected_value) { + $actualFormatted = is_array($this->actualValue) ? implode(', ', $this->actualValue) : $this->actualValue; + $message->line("Expected: {$this->dnsRecord->expected_value}") + ->line("Actual: {$actualFormatted}"); + } + } else { + $message->success() + ->line("DNS record for {$this->dnsRecord->record_type} of {$this->dnsRecord->domain} is now valid.") + ->line("The DNS configuration has been restored."); + + if ($this->actualValue) { + $actualFormatted = is_array($this->actualValue) ? implode(', ', $this->actualValue) : $this->actualValue; + $message->line("Current value: {$actualFormatted}"); + } + } + + return $message + ->line("Domain: {$this->dnsRecord->domain}") + ->line("Record Type: {$this->dnsRecord->record_type}") + ->line("Checked at: {$this->dnsRecord->last_checked_at->format('Y-m-d H:i:s')}") + ->action('View Details', url("/sites/{$this->dnsRecord->site_id}/dns-records/{$this->dnsRecord->id}")); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'dns_record', + 'dns_record_id' => $this->dnsRecord->id, + 'site_id' => $this->dnsRecord->site_id, + 'site_name' => $this->dnsRecord->site->name, + 'domain' => $this->dnsRecord->domain, + 'record_type' => $this->dnsRecord->record_type, + 'status' => $this->status, + 'is_valid' => $this->dnsRecord->is_valid, + 'expected_value' => $this->dnsRecord->expected_value, + 'actual_value' => $this->actualValue, + 'error_message' => $this->errorMessage, + 'checked_at' => $this->dnsRecord->last_checked_at, + ]; + } +} diff --git a/app/Notifications/HealthCheckAlert.php b/app/Notifications/HealthCheckAlert.php new file mode 100644 index 0000000..0677679 --- /dev/null +++ b/app/Notifications/HealthCheckAlert.php @@ -0,0 +1,97 @@ + + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $subject = $this->status === 'unhealthy' + ? "Health Check Failed: {$this->healthCheck->endpoint}" + : "Health Check Restored: {$this->healthCheck->endpoint}"; + + $message = (new MailMessage) + ->subject($subject) + ->line("Health check alert for {$this->healthCheck->site->name}"); + + if ($this->status === 'unhealthy') { + $message->error() + ->line("The health endpoint {$this->healthCheck->endpoint} is reporting as unhealthy.") + ->line("This could indicate an application issue or service degradation."); + + if ($this->statusCode) { + $message->line("HTTP Status Code: {$this->statusCode}"); + } + } else { + $message->success() + ->line("The health endpoint {$this->healthCheck->endpoint} is now healthy.") + ->line("The service has been restored."); + + if ($this->statusCode) { + $message->line("HTTP Status Code: {$this->statusCode}"); + } + + if ($this->responseTime) { + $message->line("Response Time: {$this->responseTime}ms"); + } + } + + return $message + ->line("Endpoint: {$this->healthCheck->full_url}") + ->line("Checked at: {$this->healthCheck->last_checked_at->format('Y-m-d H:i:s')}") + ->action('View Details', url("/sites/{$this->healthCheck->site_id}")); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'health_check', + 'health_check_id' => $this->healthCheck->id, + 'site_id' => $this->healthCheck->site_id, + 'site_name' => $this->healthCheck->site->name, + 'endpoint' => $this->healthCheck->endpoint, + 'full_url' => $this->healthCheck->full_url, + 'status' => $this->status, + 'is_healthy' => $this->healthCheck->is_healthy, + 'status_code' => $this->statusCode, + 'response_time_ms' => $this->responseTime, + 'checked_at' => $this->healthCheck->last_checked_at, + ]; + } +} diff --git a/app/Notifications/PerformanceAlert.php b/app/Notifications/PerformanceAlert.php new file mode 100644 index 0000000..21451db --- /dev/null +++ b/app/Notifications/PerformanceAlert.php @@ -0,0 +1,90 @@ + + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $subject = "Performance Alert: {$this->resourceName}"; + $percentageOver = round((($this->responseTime - $this->threshold) / $this->threshold) * 100); + + $message = (new MailMessage) + ->subject($subject) + ->warning() + ->line("Performance threshold exceeded for {$this->resourceType}: {$this->resourceName}") + ->line("Response time ({$this->responseTime}ms) exceeded the threshold of {$this->threshold}ms by {$percentageOver}%.") + ->line("This could indicate a performance degradation or resource constraint."); + + $message->line("Response Time: {$this->responseTime}ms") + ->line("Threshold: {$this->threshold}ms") + ->line("Difference: " . ($this->responseTime - $this->threshold) . "ms"); + + if ($this->url) { + $message->line("URL: {$this->url}"); + } + + if ($this->resourceId) { + $actionUrl = match($this->resourceType) { + 'Health Check' => url("/sites/{$this->resourceId}"), + default => url('/dashboard'), + }; + $message->action('View Details', $actionUrl); + } + + return $message; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'performance_alert', + 'resource_type' => $this->resourceType, + 'resource_name' => $this->resourceName, + 'resource_id' => $this->resourceId, + 'response_time_ms' => $this->responseTime, + 'threshold_ms' => $this->threshold, + 'difference_ms' => $this->responseTime - $this->threshold, + 'percentage_over' => round((($this->responseTime - $this->threshold) / $this->threshold) * 100, 2), + 'url' => $this->url, + ]; + } +} diff --git a/app/Notifications/TcpPortAlert.php b/app/Notifications/TcpPortAlert.php new file mode 100644 index 0000000..abe1d20 --- /dev/null +++ b/app/Notifications/TcpPortAlert.php @@ -0,0 +1,81 @@ + + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $subject = $this->status === 'closed' + ? "TCP Port {$this->tcpPort->port} is Closed" + : "TCP Port {$this->tcpPort->port} is Now Open"; + + $message = (new MailMessage) + ->subject($subject) + ->line("Port monitoring alert for {$this->tcpPort->site->name}"); + + if ($this->status === 'closed') { + $message->error() + ->line("Port {$this->tcpPort->display_name} ({$this->tcpPort->port}) is currently closed.") + ->line("This could indicate a service outage or firewall issue."); + } else { + $message->success() + ->line("Port {$this->tcpPort->display_name} ({$this->tcpPort->port}) is now accessible.") + ->line("The service has been restored."); + } + + return $message + ->line("Site: {$this->tcpPort->site->url}") + ->line("Checked at: {$this->tcpPort->last_checked_at->format('Y-m-d H:i:s')}") + ->action('View Details', url("/sites/{$this->tcpPort->site_id}")); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'tcp_port', + 'tcp_port_id' => $this->tcpPort->id, + 'site_id' => $this->tcpPort->site_id, + 'site_name' => $this->tcpPort->site->name, + 'port' => $this->tcpPort->port, + 'port_name' => $this->tcpPort->display_name, + 'status' => $this->status, + 'is_open' => $this->tcpPort->is_open, + 'checked_at' => $this->tcpPort->last_checked_at, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..c3928c5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/composer.json b/composer.json index fe21077..17385d4 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.2", "guzzlehttp/guzzle": "^7.0", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.2", "laravel/tinker": "^2.10.1", "laravel/ui": "^4.6", "spatie/crawler": "^8.4" diff --git a/composer.lock b/composer.lock index 84fd216..a119976 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d9587b74374c571fcfc09447a91fcb15", + "content-hash": "88cbc7f24cd10b72bef514dc03a4dfd8", "packages": [ { "name": "brick/math", @@ -1333,6 +1333,69 @@ }, "time": "2025-11-21T20:52:52+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.2.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "47d26f1d310879ff757b971f5a6fc631d18663fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/47d26f1d310879ff757b971f5a6fc631d18663fd", + "reference": "47d26f1d310879ff757b971f5a6fc631d18663fd", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-01-11T18:20:25+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.7", @@ -9786,5 +9849,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/migrations/2026_01_17_122420_create_personal_access_tokens_table.php b/database/migrations/2026_01_17_122420_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_01_17_122420_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_01_17_122958_add_is_online_to_uptime_checks_table.php b/database/migrations/2026_01_17_122958_add_is_online_to_uptime_checks_table.php new file mode 100644 index 0000000..1856299 --- /dev/null +++ b/database/migrations/2026_01_17_122958_add_is_online_to_uptime_checks_table.php @@ -0,0 +1,28 @@ +boolean('is_online')->nullable()->after('response_time_ms'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('uptime_checks', function (Blueprint $table) { + $table->dropColumn('is_online'); + }); + } +}; diff --git a/database/migrations/2026_01_17_124135_add_check_type_to_uptime_checks_table.php b/database/migrations/2026_01_17_124135_add_check_type_to_uptime_checks_table.php new file mode 100644 index 0000000..baa0e3d --- /dev/null +++ b/database/migrations/2026_01_17_124135_add_check_type_to_uptime_checks_table.php @@ -0,0 +1,28 @@ +string('check_type', 10)->default('http')->after('site_id')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('uptime_checks', function (Blueprint $table) { + $table->dropColumn('check_type'); + }); + } +}; diff --git a/database/migrations/2026_01_17_125655_create_tcp_ports_table.php b/database/migrations/2026_01_17_125655_create_tcp_ports_table.php new file mode 100644 index 0000000..5bb888d --- /dev/null +++ b/database/migrations/2026_01_17_125655_create_tcp_ports_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('site_id')->constrained()->onDelete('cascade'); + $table->integer('port'); + $table->string('name')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamp('last_checked_at')->nullable(); + $table->boolean('is_open')->nullable(); + $table->integer('response_time_ms')->nullable(); + $table->timestamps(); + + // Indexes + $table->index('site_id'); + $table->index('is_active'); + $table->index(['site_id', 'port']); + $table->index('last_checked_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tcp_ports'); + } +}; diff --git a/database/migrations/2026_01_17_131400_create_health_checks_table.php b/database/migrations/2026_01_17_131400_create_health_checks_table.php new file mode 100644 index 0000000..d7be3d1 --- /dev/null +++ b/database/migrations/2026_01_17_131400_create_health_checks_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('site_id')->constrained()->onDelete('cascade'); + $table->string('endpoint'); + $table->json('expected_json_keys')->nullable(); + $table->timestamp('last_checked_at')->nullable(); + $table->boolean('is_healthy')->nullable(); + $table->json('response_data')->nullable(); + $table->integer('response_time_ms')->nullable(); + $table->integer('status_code')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + // Indexes + $table->index('site_id'); + $table->index('is_active'); + $table->index('is_healthy'); + $table->index('last_checked_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('health_checks'); + } +}; diff --git a/database/migrations/2026_01_17_140635_add_performance_threshold_to_health_checks_table.php b/database/migrations/2026_01_17_140635_add_performance_threshold_to_health_checks_table.php new file mode 100644 index 0000000..88e9a89 --- /dev/null +++ b/database/migrations/2026_01_17_140635_add_performance_threshold_to_health_checks_table.php @@ -0,0 +1,28 @@ +integer('performance_threshold')->nullable()->after('response_time_ms'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('health_checks', function (Blueprint $table) { + $table->dropColumn('performance_threshold'); + }); + } +}; diff --git a/database/migrations/2026_01_17_141144_create_dns_records_table.php b/database/migrations/2026_01_17_141144_create_dns_records_table.php new file mode 100644 index 0000000..caddab2 --- /dev/null +++ b/database/migrations/2026_01_17_141144_create_dns_records_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('site_id')->constrained()->onDelete('cascade'); + $table->string('domain'); + $table->string('record_type'); // A, AAAA, MX, CNAME, TXT, NS, SOA + $table->text('expected_value')->nullable(); + $table->timestamp('last_checked_at')->nullable(); + $table->boolean('is_valid')->nullable(); + $table->json('actual_value')->nullable(); + $table->text('error_message')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + // Indexes + $table->index('site_id'); + $table->index('is_active'); + $table->index('is_valid'); + $table->index('last_checked_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('dns_records'); + } +}; diff --git a/resources/views/dns-records/create.blade.php b/resources/views/dns-records/create.blade.php new file mode 100644 index 0000000..8931fd7 --- /dev/null +++ b/resources/views/dns-records/create.blade.php @@ -0,0 +1,148 @@ +@extends('layouts.app') + +@section('title', 'Add DNS Record - ' . $site->name) + +@section('content') +
+ +
+ +
+
+

Add DNS Record

+ +
+
+
+ @csrf + +
+ + + @error('domain') +
{{ $message }}
+ @enderror +
Enter the domain name to monitor (e.g., example.com, subdomain.example.com)
+
+ +
+ + + @error('record_type') +
{{ $message }}
+ @enderror +
Select the DNS record type to monitor
+
+ +
+ + + @error('expected_value') +
{{ $message }}
+ @enderror +
+ Specify the expected value to validate against. Leave empty to only check if the record exists. +
+
+ +
+
+ + +
+
+ +
+ + + Cancel + +
+
+
+
+
+ +
+
+
+
DNS Record Types
+
+
+
+
A
+
Maps domain to IPv4 address
+ +
AAAA
+
Maps domain to IPv6 address
+ +
CNAME
+
Alias to another domain
+ +
MX
+
Mail server records
+ +
TXT
+
Text records (SPF, DKIM, etc.)
+ +
NS
+
Name server records
+ +
SOA
+
Start of authority
+
+
+
+ +
+
+
Tips
+
+
+
    +
  • DNS checks run hourly
  • +
  • Leave expected value empty to only verify record exists
  • +
  • For MX records, include priority (e.g., "10 mail.example.com")
  • +
  • Alerts are sent when records become invalid
  • +
+
+
+
+
+@endsection diff --git a/resources/views/dns-records/edit.blade.php b/resources/views/dns-records/edit.blade.php new file mode 100644 index 0000000..2a5d8b4 --- /dev/null +++ b/resources/views/dns-records/edit.blade.php @@ -0,0 +1,142 @@ +@extends('layouts.app') + +@section('title', 'Edit DNS Record - ' . $site->name) + +@section('content') +
+ +
+ +
+
+

Edit DNS Record

+ +
+
+
+ @csrf + @method('PUT') + +
+ + + @error('domain') +
{{ $message }}
+ @enderror +
Enter the domain name to monitor
+
+ +
+ + + @error('record_type') +
{{ $message }}
+ @enderror +
Select the DNS record type to monitor
+
+ +
+ + + @error('expected_value') +
{{ $message }}
+ @enderror +
+ Specify the expected value to validate against. Leave empty to only check if the record exists. +
+
+ +
+
+ is_active) ? 'checked' : '' }}> + +
+
+ +
+ + + Cancel + +
+
+
+
+
+ +
+
+
+
Current Status
+
+
+
+
Status:
+
+ + {{ $dnsRecord->status_text }} + +
+ +
Last Checked:
+
+ @if($dnsRecord->last_checked_at) + {{ $dnsRecord->last_checked_at->diffForHumans() }} + @else + Never + @endif +
+ + @if($dnsRecord->formatted_actual_value) +
Current Value:
+
+ {{ Str::limit($dnsRecord->formatted_actual_value, 40) }} +
+ @endif + + @if($dnsRecord->error_message) +
Error:
+
+ {{ Str::limit($dnsRecord->error_message, 40) }} +
+ @endif +
+
+
+
+
+@endsection diff --git a/resources/views/dns-records/index.blade.php b/resources/views/dns-records/index.blade.php new file mode 100644 index 0000000..1c822c1 --- /dev/null +++ b/resources/views/dns-records/index.blade.php @@ -0,0 +1,109 @@ +@extends('layouts.app') + +@section('title', 'DNS Records - ' . $site->name) + +@section('content') +
+ +
+ +
+

DNS Monitoring

+ + Add DNS Record + +
+ +@if(session('success')) +
+ {{ session('success') }} + +
+@endif + +@if($dnsRecords->isEmpty()) +
+ No DNS records are being monitored for this site yet. Click "Add DNS Record" to start monitoring. +
+@else +
+ + + + + + + + + + + + + + @foreach($dnsRecords as $record) + + + + + + + + + + @endforeach + +
DomainTypeStatusExpected ValueLast CheckedActiveActions
{{ $record->domain }} + {{ $record->record_type }} + + + {{ $record->status_text }} + + + @if($record->expected_value) + {{ Str::limit($record->expected_value, 30) }} + @else + — + @endif + + @if($record->last_checked_at) + {{ $record->last_checked_at->diffForHumans() }} + @else + Never + @endif + + @if($record->is_active) + Active + @else + Inactive + @endif + +
+ + + + + + +
+ @csrf + @method('DELETE') + +
+
+
+
+@endif +@endsection diff --git a/resources/views/dns-records/show.blade.php b/resources/views/dns-records/show.blade.php new file mode 100644 index 0000000..a0db4c7 --- /dev/null +++ b/resources/views/dns-records/show.blade.php @@ -0,0 +1,226 @@ +@extends('layouts.app') + +@section('title', 'DNS Record - ' . $dnsRecord->display_name . ' - ' . $site->name) + +@section('content') +
+ +
+ +
+

{{ $dnsRecord->record_type }} - {{ $dnsRecord->domain }}

+
+ + Edit + +
+ @csrf + @method('DELETE') + +
+
+
+ +@if(session('success')) +
+ {{ session('success') }} + +
+@endif + +
+
+
+
+
DNS Record Details
+
+
+
+
Site
+
+ {{ $site->name }} +
+ {{ $site->url }} +
+ +
Domain
+
{{ $dnsRecord->domain }}
+ +
Record Type
+
+ {{ $dnsRecord->record_type }} +
+ +
Status
+
+ + {{ $dnsRecord->status_text }} + +
+ +
Monitoring
+
+ @if($dnsRecord->is_active) + Active + @else + Inactive + @endif +
+ +
Last Checked
+
+ @if($dnsRecord->last_checked_at) + {{ $dnsRecord->last_checked_at->format('Y-m-d H:i:s') }} +
+ {{ $dnsRecord->last_checked_at->diffForHumans() }} + @else + Never + @endif +
+ +
Expected Value
+
+ @if($dnsRecord->expected_value) + {{ $dnsRecord->expected_value }} + @else + Not specified (only checking existence) + @endif +
+ + @if($dnsRecord->actual_value) +
Actual Value(s)
+
+ @if(is_array($dnsRecord->actual_value)) +
    + @foreach($dnsRecord->actual_value as $value) +
  • {{ $value }}
  • + @endforeach +
+ @else + {{ $dnsRecord->actual_value }} + @endif +
+ @endif + + @if($dnsRecord->error_message) +
Error
+
+
+ {{ $dnsRecord->error_message }} +
+
+ @endif + +
Created
+
+ {{ $dnsRecord->created_at->format('Y-m-d H:i:s') }} +
+ {{ $dnsRecord->created_at->diffForHumans() }} +
+
+
+
+ +
+
+
About {{ $dnsRecord->record_type }} Records
+
+
+ @php + $descriptions = [ + 'A' => 'A records map a domain name to an IPv4 address. This is the most common type of DNS record.', + 'AAAA' => 'AAAA records map a domain name to an IPv6 address, the newer version of IP addressing.', + 'CNAME' => 'CNAME records create an alias from one domain name to another. They\'re often used for subdomains.', + 'MX' => 'MX records specify the mail servers responsible for accepting email on behalf of your domain.', + 'TXT' => 'TXT records store text information. They\'re commonly used for email authentication (SPF, DKIM, DMARC).', + 'NS' => 'NS records identify the authoritative name servers for your domain.', + 'SOA' => 'SOA records contain administrative information about the domain, including the primary name server.', + 'PTR' => 'PTR records provide reverse DNS lookups, mapping IP addresses back to domain names.', + 'SRV' => 'SRV records specify the location of services, including the hostname and port number.', + ]; + @endphp +

{{ $descriptions[$dnsRecord->record_type] ?? 'DNS record type for monitoring.' }}

+
+
+
+ +
+
+
+
Current Status
+
+
+
+ + {{ $dnsRecord->status_text }} + +
+ + @if($dnsRecord->is_valid === true) +

+ DNS record is valid +

+ @elseif($dnsRecord->is_valid === false) +

+ DNS record is invalid +

+ @else +

+ Not yet checked +

+ @endif +
+
+ +
+
+
Monitoring Info
+
+
+

+ Check Frequency: Hourly +

+

+ Alerts: Sent when DNS becomes invalid +

+

+ Validation: + @if($dnsRecord->expected_value) + Matches expected value + @else + Record exists + @endif +

+
+
+ +
+
+
Quick Actions
+
+ +
+
+
+@endsection diff --git a/resources/views/health-checks/create.blade.php b/resources/views/health-checks/create.blade.php new file mode 100644 index 0000000..3df8a53 --- /dev/null +++ b/resources/views/health-checks/create.blade.php @@ -0,0 +1,141 @@ +@extends('layouts.app') + +@section('title', 'Add Health Check - ' . $site->name) + +@section('content') +
+ +
+ +
+
+

Add Health Check

+ +
+
+
+ @csrf + +
+ +
+ {{ rtrim($site->url, '/') }}/ + + @error('endpoint') +
{{ $message }}
+ @enderror +
+
Enter the path to your health check endpoint (e.g., "health", "api/health", "status").
+
+ +
+ + + @error('expected_json_keys') +
{{ $message }}
+ @enderror +
+ Comma-separated list of JSON keys that must be present in the response. Supports dot notation for nested keys (e.g., "data.status, meta.version"). +
+
+ +
+ +
+ + ms + @error('performance_threshold') +
{{ $message }}
+ @enderror +
+
+ Alert when response time exceeds this threshold in milliseconds. Leave empty to disable performance alerts. +
+
+ +
+
+ + +
+
+ +
+ + + Cancel + +
+
+
+
+
+ +
+
+
+
Health Check Guide
+
+
+
What is a Health Check?
+

+ A health check endpoint is a URL that returns information about your application's status, including database connectivity, cache availability, and other critical services. +

+ +
Common Endpoints
+
    +
  • /health
  • +
  • /api/health
  • +
  • /status
  • +
  • /healthz
  • +
  • /.well-known/health
  • +
+ +
Response Format
+

+ Your endpoint should return JSON with a 2xx status code when healthy. Example: +

+
{
+  "status": "healthy",
+  "database": "ok",
+  "cache": "ok"
+}
+
+
+
+
+@endsection diff --git a/resources/views/health-checks/edit.blade.php b/resources/views/health-checks/edit.blade.php new file mode 100644 index 0000000..45494c9 --- /dev/null +++ b/resources/views/health-checks/edit.blade.php @@ -0,0 +1,149 @@ +@extends('layouts.app') + +@section('title', 'Edit Health Check - ' . $site->name) + +@section('content') +
+ +
+ +
+
+

Edit Health Check

+ +
+
+
+ @csrf + @method('PUT') + +
+ +
+ {{ rtrim($site->url, '/') }}/ + + @error('endpoint') +
{{ $message }}
+ @enderror +
+
Enter the path to your health check endpoint.
+
+ +
+ + + @error('expected_json_keys') +
{{ $message }}
+ @enderror +
+ Comma-separated list of JSON keys that must be present in the response. Supports dot notation for nested keys. +
+
+ +
+ +
+ + ms + @error('performance_threshold') +
{{ $message }}
+ @enderror +
+
+ Alert when response time exceeds this threshold in milliseconds. Leave empty to disable performance alerts. +
+
+ +
+
+ is_active) ? 'checked' : '' }}> + +
+
+ +
+ + + Cancel + +
+
+
+
+
+ +
+
+
+
Current Status
+
+
+
+
Status:
+
+ + {{ $healthCheck->status_text }} + +
+ +
Last Checked:
+
+ @if($healthCheck->last_checked_at) + {{ $healthCheck->last_checked_at->diffForHumans() }} + @else + Never + @endif +
+ + @if($healthCheck->status_code) +
Status Code:
+
+ + {{ $healthCheck->status_code }} + +
+ @endif + + @if($healthCheck->response_time_ms) +
Response Time:
+
{{ $healthCheck->response_time_ms }}ms
+ @endif +
+
+
+
+
+@endsection diff --git a/resources/views/health-checks/index.blade.php b/resources/views/health-checks/index.blade.php new file mode 100644 index 0000000..056246d --- /dev/null +++ b/resources/views/health-checks/index.blade.php @@ -0,0 +1,123 @@ +@extends('layouts.app') + +@section('title', 'Health Checks - ' . $site->name) + +@section('content') +
+ +
+ +
+

Health Check Monitoring

+ + Add Health Check + +
+ +@if(session('success')) +
+ {{ session('success') }} + +
+@endif + +@if($healthChecks->isEmpty()) +
+ No health check endpoints are being monitored for this site yet. Click "Add Health Check" to start monitoring. +
+@else +
+ + + + + + + + + + + + + + @foreach($healthChecks as $healthCheck) + + + + + + + + + + @endforeach + +
EndpointStatusStatus CodeResponse TimeLast CheckedActiveActions
+ {{ $healthCheck->endpoint }} + @if($healthCheck->expected_json_keys) +
+ + Validates: {{ implode(', ', $healthCheck->expected_json_keys) }} + + @endif +
+ + {{ $healthCheck->status_text }} + + + @if($healthCheck->status_code) + + {{ $healthCheck->status_code }} + + @else + — + @endif + + @if($healthCheck->response_time_ms) + {{ $healthCheck->response_time_ms }}ms + @else + — + @endif + + @if($healthCheck->last_checked_at) + {{ $healthCheck->last_checked_at->diffForHumans() }} + @else + Never + @endif + + @if($healthCheck->is_active) + Active + @else + Inactive + @endif + +
+ + + + + + +
+ @csrf + @method('DELETE') + +
+
+
+
+@endif +@endsection diff --git a/resources/views/health-checks/show.blade.php b/resources/views/health-checks/show.blade.php new file mode 100644 index 0000000..54f89df --- /dev/null +++ b/resources/views/health-checks/show.blade.php @@ -0,0 +1,231 @@ +@extends('layouts.app') + +@section('title', 'Health Check - ' . $healthCheck->endpoint . ' - ' . $site->name) + +@section('content') +
+ +
+ +
+

{{ $healthCheck->endpoint }}

+
+ + Edit + +
+ @csrf + @method('DELETE') + +
+
+
+ +@if(session('success')) +
+ {{ session('success') }} + +
+@endif + +
+
+
+
+
Health Check Details
+
+
+
+
Site
+
+ {{ $site->name }} +
+ {{ $site->url }} +
+ +
Endpoint URL
+
+ + {{ $healthCheck->full_url }} + + +
+ +
Status
+
+ + {{ $healthCheck->status_text }} + +
+ +
Monitoring
+
+ @if($healthCheck->is_active) + Active + @else + Inactive + @endif +
+ +
Last Checked
+
+ @if($healthCheck->last_checked_at) + {{ $healthCheck->last_checked_at->format('Y-m-d H:i:s') }} +
+ {{ $healthCheck->last_checked_at->diffForHumans() }} + @else + Never + @endif +
+ + @if($healthCheck->status_code) +
Status Code
+
+ + {{ $healthCheck->status_code }} + +
+ @endif + + @if($healthCheck->response_time_ms) +
Response Time
+
{{ $healthCheck->response_time_ms }}ms
+ @endif + +
Expected Keys
+
+ @if($healthCheck->expected_json_keys && count($healthCheck->expected_json_keys) > 0) + @foreach($healthCheck->expected_json_keys as $key) + {{ $key }} + @endforeach + @else + None specified + @endif +
+ +
Performance Threshold
+
+ @if($healthCheck->performance_threshold) + {{ $healthCheck->performance_threshold }}ms + @if($healthCheck->response_time_ms && $healthCheck->response_time_ms > $healthCheck->performance_threshold) +
Currently exceeding threshold + @endif + @else + Not set + @endif +
+ +
Created
+
+ {{ $healthCheck->created_at->format('Y-m-d H:i:s') }} +
+ {{ $healthCheck->created_at->diffForHumans() }} +
+
+
+
+ + @if($healthCheck->response_data) +
+
+
Latest Response Data
+
+
+ @if(isset($healthCheck->response_data['error'])) +
+ + Error: {{ $healthCheck->response_data['error'] }} +
+ @else +
{{ json_encode($healthCheck->response_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+ @endif +
+
+ @endif +
+ +
+
+
+
Current Status
+
+
+
+ + {{ $healthCheck->status_text }} + +
+ + @if($healthCheck->is_healthy === true) +

+ All checks passed +

+ @elseif($healthCheck->is_healthy === false) +

+ Health check failed +

+ @else +

+ Not yet checked +

+ @endif +
+
+ +
+
+
Monitoring Info
+
+
+

+ Check Frequency: Every 5 minutes +

+

+ Requirements: +

+
    +
  • 2xx HTTP status code
  • +
  • Valid JSON response
  • + @if($healthCheck->expected_json_keys && count($healthCheck->expected_json_keys) > 0) +
  • Contains required keys: {{ implode(', ', $healthCheck->expected_json_keys) }}
  • + @endif + @if($healthCheck->performance_threshold) +
  • Performance alert if exceeds {{ $healthCheck->performance_threshold }}ms
  • + @endif +
+
+
+ + +
+
+@endsection diff --git a/resources/views/tcp-ports/create.blade.php b/resources/views/tcp-ports/create.blade.php new file mode 100644 index 0000000..01f75ec --- /dev/null +++ b/resources/views/tcp-ports/create.blade.php @@ -0,0 +1,118 @@ +@extends('layouts.app') + +@section('title', 'Add TCP Port - ' . $site->name) + +@section('content') +
+ +
+ +
+
+

Add TCP Port

+ +
+
+
+ @csrf + +
+ + + @error('port') +
{{ $message }}
+ @enderror +
Enter a port number between 1 and 65535.
+
+ +
+ + + @error('name') +
{{ $message }}
+ @enderror +
Optional custom name for this port.
+
+ +
+
+ + +
+
+ +
+ + + Cancel + +
+
+
+
+
+ +
+
+
+
Common Ports
+
+
+ Click to use: +
+ @foreach($commonPorts as $portNumber => $portName) + + {{ $portNumber }} - {{ $portName }} + + @endforeach +
+
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/tcp-ports/edit.blade.php b/resources/views/tcp-ports/edit.blade.php new file mode 100644 index 0000000..7bc7965 --- /dev/null +++ b/resources/views/tcp-ports/edit.blade.php @@ -0,0 +1,151 @@ +@extends('layouts.app') + +@section('title', 'Edit TCP Port - ' . $site->name) + +@section('content') +
+ +
+ +
+
+

Edit TCP Port

+ +
+
+
+ @csrf + @method('PUT') + +
+ + + @error('port') +
{{ $message }}
+ @enderror +
Enter a port number between 1 and 65535.
+
+ +
+ + + @error('name') +
{{ $message }}
+ @enderror +
Optional custom name for this port.
+
+ +
+
+ is_active) ? 'checked' : '' }}> + +
+
+ +
+ + + Cancel + +
+
+
+
+
+ +
+
+
+
Port Status
+
+
+
+
Current Status
+
+ + {{ $tcpPort->status_text }} + +
+ +
Last Checked
+
+ @if($tcpPort->last_checked_at) + {{ $tcpPort->last_checked_at->format('Y-m-d H:i:s') }} +
+ {{ $tcpPort->last_checked_at->diffForHumans() }} + @else + Never + @endif +
+ + @if($tcpPort->response_time_ms) +
Response Time
+
{{ $tcpPort->response_time_ms }}ms
+ @endif +
+
+
+ +
+
+
Common Ports
+
+
+ Click to use: +
+ @foreach($commonPorts as $portNumber => $portName) + + {{ $portNumber }} - {{ $portName }} + + @endforeach +
+
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/tcp-ports/index.blade.php b/resources/views/tcp-ports/index.blade.php new file mode 100644 index 0000000..154f66a --- /dev/null +++ b/resources/views/tcp-ports/index.blade.php @@ -0,0 +1,109 @@ +@extends('layouts.app') + +@section('title', 'TCP Ports - ' . $site->name) + +@section('content') +
+ +
+ +
+

TCP Port Monitoring

+ + Add Port + +
+ +@if(session('success')) +
+ {{ session('success') }} + +
+@endif + +@if($tcpPorts->isEmpty()) +
+ No TCP ports are being monitored for this site yet. Click "Add Port" to start monitoring. +
+@else +
+ + + + + + + + + + + + + + @foreach($tcpPorts as $port) + + + + + + + + + + @endforeach + +
PortNameStatusResponse TimeLast CheckedActiveActions
{{ $port->port }}{{ $port->display_name }} + + {{ $port->status_text }} + + + @if($port->response_time_ms) + {{ $port->response_time_ms }}ms + @else + — + @endif + + @if($port->last_checked_at) + {{ $port->last_checked_at->diffForHumans() }} + @else + Never + @endif + + @if($port->is_active) + Active + @else + Inactive + @endif + +
+ + + + + + +
+ @csrf + @method('DELETE') + +
+
+
+
+ + {{ $tcpPorts->links() }} +@endif +@endsection diff --git a/resources/views/tcp-ports/show.blade.php b/resources/views/tcp-ports/show.blade.php new file mode 100644 index 0000000..f678f4e --- /dev/null +++ b/resources/views/tcp-ports/show.blade.php @@ -0,0 +1,168 @@ +@extends('layouts.app') + +@section('title', 'TCP Port ' . $tcpPort->port . ' - ' . $site->name) + +@section('content') +
+ +
+ +
+

Port {{ $tcpPort->port }} - {{ $tcpPort->display_name }}

+
+ + Edit + +
+ @csrf + @method('DELETE') + +
+
+
+ +
+
+
+
+
Port Details
+
+
+
+
Site
+
+ {{ $site->name }} +
+ {{ $site->url }} +
+ +
Port Number
+
{{ $tcpPort->port }}
+ +
Port Name
+
{{ $tcpPort->display_name }}
+ +
Status
+
+ + {{ $tcpPort->status_text }} + +
+ +
Monitoring
+
+ @if($tcpPort->is_active) + Active + @else + Inactive + @endif +
+ +
Last Checked
+
+ @if($tcpPort->last_checked_at) + {{ $tcpPort->last_checked_at->format('Y-m-d H:i:s') }} +
+ {{ $tcpPort->last_checked_at->diffForHumans() }} + @else + Never + @endif +
+ + @if($tcpPort->response_time_ms) +
Response Time
+
{{ $tcpPort->response_time_ms }}ms
+ @endif + +
Created
+
+ {{ $tcpPort->created_at->format('Y-m-d H:i:s') }} +
+ {{ $tcpPort->created_at->diffForHumans() }} +
+
+
+
+ +
+
+
About Port {{ $tcpPort->port }}
+
+
+ @php + $commonPorts = \App\Models\TcpPort::getCommonPorts(); + $portInfo = $commonPorts[$tcpPort->port] ?? null; + @endphp + + @if($portInfo) +

+ Common Service: {{ $portInfo }} +

+ @endif + +

+ This monitor checks if port {{ $tcpPort->port }} is accessible on {{ $site->url }}. + Checks run every 10 minutes when monitoring is active. +

+
+
+
+ +
+
+
+
Current Status
+
+
+
+ + {{ $tcpPort->status_text }} + +
+ + @if($tcpPort->is_open) +

+ Port is accessible +

+ @elseif($tcpPort->is_open === false) +

+ Port is not accessible +

+ @else +

+ Not yet checked +

+ @endif +
+
+ +
+
+
Quick Actions
+
+ +
+
+
+@endsection diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..96a7bd3 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,48 @@ +json(['message' => 'API is working']); +}); + +Route::middleware('auth:sanctum')->group(function () { + // Authentication & Token Management + Route::post('/tokens', [AuthController::class, 'createToken']); + Route::get('/tokens', [AuthController::class, 'tokens']); + Route::delete('/tokens/{tokenId}', [AuthController::class, 'revokeToken']); + Route::delete('/tokens', [AuthController::class, 'revokeAllTokens']); + + // Sites + Route::get('/sites', [SiteController::class, 'index']); + Route::get('/sites/{site}', [SiteController::class, 'show']); + + // Uptime + Route::get('/sites/{site}/uptime', [UptimeController::class, 'index']); + Route::get('/sites/{site}/uptime/stats', [UptimeController::class, 'stats']); + + // SSL + Route::get('/sites/{site}/ssl', [SslController::class, 'show']); + Route::get('/sites/{site}/ssl/history', [SslController::class, 'history']); + + // Broken Links + Route::get('/sites/{site}/links', [LinkController::class, 'index']); + Route::get('/sites/{site}/links/stats', [LinkController::class, 'stats']); +}); diff --git a/routes/console.php b/routes/console.php index 1bc6a8c..27646d1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -15,6 +15,13 @@ Schedule::command('monitor:uptime') ->runInBackground() ->appendOutputTo(storage_path('logs/uptime-monitoring.log')); +// Schedule ping monitoring to run every 5 minutes +Schedule::command('monitor:ping') + ->everyFiveMinutes() + ->withoutOverlapping() + ->runInBackground() + ->appendOutputTo(storage_path('logs/ping-monitoring.log')); + // Schedule SSL certificate monitoring to run daily Schedule::command('monitor:ssl') ->daily() @@ -28,3 +35,24 @@ Schedule::command('monitor:links') ->withoutOverlapping() ->runInBackground() ->appendOutputTo(storage_path('logs/link-monitoring.log')); + +// Schedule TCP port monitoring to run every 10 minutes +Schedule::command('monitor:tcp-ports') + ->everyTenMinutes() + ->withoutOverlapping() + ->runInBackground() + ->appendOutputTo(storage_path('logs/tcp-port-monitoring.log')); + +// Schedule health endpoint monitoring to run every 5 minutes +Schedule::command('monitor:health') + ->everyFiveMinutes() + ->withoutOverlapping() + ->runInBackground() + ->appendOutputTo(storage_path('logs/health-monitoring.log')); + +// Schedule DNS monitoring to run hourly +Schedule::command('monitor:dns') + ->hourly() + ->withoutOverlapping() + ->runInBackground() + ->appendOutputTo(storage_path('logs/dns-monitoring.log')); diff --git a/routes/web.php b/routes/web.php index 4b404b9..7c8cf73 100644 --- a/routes/web.php +++ b/routes/web.php @@ -22,6 +22,13 @@ Route::middleware('auth')->group(function () { Route::resource('sites', SiteController::class); Route::get('dashboard/{site}', [DashboardController::class, 'show'])->name('dashboard.show'); + // Resources nested under sites + Route::prefix('sites/{site}')->group(function () { + Route::resource('tcp-ports', \App\Http\Controllers\TcpPortController::class); + Route::resource('health-checks', \App\Http\Controllers\HealthCheckController::class); + Route::resource('dns-records', \App\Http\Controllers\DnsRecordController::class); + }); + // Alerts Route::get('/alerts', [AlertController::class, 'index'])->name('alerts.index'); Route::patch('/alerts/{notification}/read', [AlertController::class, 'markAsRead'])->name('alerts.markAsRead');