some progress

This commit is contained in:
Doar Mihai
2026-01-17 16:42:43 +02:00
parent 030a105992
commit 3665a1d5a3
57 changed files with 5804 additions and 3 deletions
+406
View File
@@ -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
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CheckDnsRecord;
use App\Models\DnsRecord;
use App\Models\Site;
use Illuminate\Console\Command;
class MonitorDns extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'monitor:dns
{--dns-record= : Specific DNS record ID to monitor}
{--site= : Monitor all DNS records for a specific site ID}
{--sync : Run checks synchronously instead of queuing}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Monitor DNS records for all sites';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->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;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CheckHealthEndpoint;
use App\Models\HealthCheck;
use App\Models\Site;
use Illuminate\Console\Command;
class MonitorHealth extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'monitor:health
{--health-check= : Specific health check ID to monitor}
{--site= : Monitor all health checks for a specific site ID}
{--sync : Run checks synchronously instead of queuing}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Monitor application health endpoints';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->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;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CheckSitePing;
use App\Models\Site;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class MonitorPing extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'monitor:ping
{--site= : Specific site ID to ping}
{--sync : Run synchronously instead of dispatching jobs}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Ping all enabled sites to check their uptime status';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->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;
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CheckTcpPort;
use App\Models\TcpPort;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class MonitorTcpPorts extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'monitor:tcp-ports
{--tcp-port= : Specific TCP port ID to check}
{--site= : Specific site ID to check all ports for}
{--sync : Run synchronously instead of dispatching jobs}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Monitor all active TCP ports';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->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;
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Create a new API token for the authenticated user.
*/
public function createToken(Request $request): JsonResponse
{
$validated = $request->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',
]);
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\BrokenLinksIndexRequest;
use App\Models\Site;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LinkController extends Controller
{
/**
* Get broken links list for a site.
*/
public function index(BrokenLinksIndexRequest $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', 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,
],
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Site;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SiteController extends Controller
{
/**
* List all sites for the authenticated user.
*/
public function index(Request $request): JsonResponse
{
$sites = $request->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,
]);
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\SslHistoryRequest;
use App\Models\Site;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SslController extends Controller
{
/**
* Get SSL certificate data for a site.
*/
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);
}
$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);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\UptimeIndexRequest;
use App\Models\Site;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UptimeController extends Controller
{
/**
* Get uptime history for a site (paginated).
*/
public function index(UptimeIndexRequest $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', 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,
],
]);
}
}
@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers;
use App\Models\DnsRecord;
use App\Models\Site;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class DnsRecordController extends Controller
{
/**
* Display a listing of the DNS records for a site.
*/
public function index(Site $site): View
{
// Ensure the user owns the site
if ($site->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.');
}
}
@@ -0,0 +1,185 @@
<?php
namespace App\Http\Controllers;
use App\Models\HealthCheck;
use App\Models\Site;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class HealthCheckController extends Controller
{
/**
* Display a listing of the health checks for a site.
*/
public function index(Site $site): View
{
// Ensure the user owns the site
if ($site->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.');
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace App\Http\Controllers;
use App\Models\Site;
use App\Models\TcpPort;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class TcpPortController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request, Site $site)
{
// Ensure the site belongs to the authenticated user
if ($site->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.');
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class BrokenLinksIndexRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class SslHistoryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class UptimeIndexRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}
+267
View File
@@ -0,0 +1,267 @@
<?php
namespace App\Jobs;
use App\Models\DnsRecord;
use App\Notifications\DnsRecordAlert;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CheckDnsRecord implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 3;
/**
* The number of seconds the job can run before timing out.
*
* @var int
*/
public $timeout = 30;
/**
* Create a new job instance.
*/
public function __construct(
public DnsRecord $dnsRecord
) {}
/**
* Execute the job.
*/
public function handle(): void
{
try {
Log::info('CheckDnsRecord: Starting check', [
'dns_record_id' => $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(),
]);
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
namespace App\Jobs;
use App\Models\HealthCheck;
use App\Notifications\HealthCheckAlert;
use App\Notifications\PerformanceAlert;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CheckHealthEndpoint implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 3;
/**
* The number of seconds the job can run before timing out.
*
* @var int
*/
public $timeout = 60;
/**
* Create a new job instance.
*/
public function __construct(
public HealthCheck $healthCheck
) {}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$client = new Client([
'timeout' => 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(),
]);
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
namespace App\Jobs;
use App\Models\Site;
use App\Models\UptimeCheck;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CheckSitePing implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 3;
/**
* The number of seconds the job can run before timing out.
*
* @var int
*/
public $timeout = 60;
/**
* Create a new job instance.
*/
public function __construct(
public Site $site
) {}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$host = $this->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(),
]);
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace App\Jobs;
use App\Models\TcpPort;
use App\Notifications\TcpPortAlert;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CheckTcpPort implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 3;
/**
* The number of seconds the job can run before timing out.
*
* @var int
*/
public $timeout = 30;
/**
* Create a new job instance.
*/
public function __construct(
public TcpPort $tcpPort
) {}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$host = $this->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(),
]);
}
}
+2
View File
@@ -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(),
]);
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DnsRecord extends Model
{
protected $fillable = [
'site_id',
'domain',
'record_type',
'expected_value',
'last_checked_at',
'is_valid',
'actual_value',
'error_message',
'is_active',
];
protected $casts = [
'last_checked_at' => '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;
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HealthCheck extends Model
{
protected $fillable = [
'site_id',
'endpoint',
'expected_json_keys',
'last_checked_at',
'is_healthy',
'response_data',
'response_time_ms',
'performance_threshold',
'status_code',
'is_active',
];
protected $casts = [
'expected_json_keys' => '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;
}
}
+15
View File
@@ -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);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TcpPort extends Model
{
protected $fillable = [
'site_id',
'port',
'name',
'is_active',
'last_checked_at',
'is_open',
'response_time_ms',
];
protected $casts = [
'is_active' => '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';
}
}
+27
View File
@@ -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);
+2 -1
View File
@@ -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.
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Notifications;
use App\Models\DnsRecord;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class DnsRecordAlert extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public DnsRecord $dnsRecord,
public string $status,
public ?array $actualValue = null,
public ?string $errorMessage = null
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
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<string, mixed>
*/
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,
];
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Notifications;
use App\Models\HealthCheck;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class HealthCheckAlert extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public HealthCheck $healthCheck,
public string $status,
public ?int $statusCode = null,
public ?int $responseTime = null
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
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<string, mixed>
*/
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,
];
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Notifications;
use App\Models\HealthCheck;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class PerformanceAlert extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public string $resourceType,
public string $resourceName,
public int $responseTime,
public int $threshold,
public ?string $url = null,
public ?int $resourceId = null
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
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<string, mixed>
*/
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,
];
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Notifications;
use App\Models\TcpPort;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TcpPortAlert extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public TcpPort $tcpPort,
public string $status
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
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<string, mixed>
*/
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,
];
}
}
+1
View File
@@ -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',
)
+1
View File
@@ -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"
Generated
+65 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "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"
}
+84
View File
@@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => 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,
],
];
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('uptime_checks', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('uptime_checks', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tcp_ports', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('health_checks', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('health_checks', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('dns_records', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,148 @@
@extends('layouts.app')
@section('title', 'Add DNS Record - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item"><a href="{{ route('dns-records.index', $site) }}">DNS Records</a></li>
<li class="breadcrumb-item active">Add DNS Record</li>
</ol>
</nav>
</div>
<div class="row">
<div class="col-md-8">
<h1 class="mb-4"><i class="bi bi-plus-circle"></i> Add DNS Record</h1>
<div class="card">
<div class="card-body">
<form action="{{ route('dns-records.store', $site) }}" method="POST">
@csrf
<div class="mb-3">
<label for="domain" class="form-label">Domain Name</label>
<input type="text"
class="form-control @error('domain') is-invalid @enderror"
id="domain"
name="domain"
value="{{ old('domain') }}"
placeholder="example.com"
required>
@error('domain')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Enter the domain name to monitor (e.g., example.com, subdomain.example.com)</div>
</div>
<div class="mb-3">
<label for="record_type" class="form-label">Record Type</label>
<select class="form-select @error('record_type') is-invalid @enderror"
id="record_type"
name="record_type"
required>
<option value="">Select record type...</option>
@foreach($recordTypes as $value => $label)
<option value="{{ $value }}" {{ old('record_type') == $value ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
@error('record_type')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Select the DNS record type to monitor</div>
</div>
<div class="mb-3">
<label for="expected_value" class="form-label">Expected Value (Optional)</label>
<textarea class="form-control @error('expected_value') is-invalid @enderror"
id="expected_value"
name="expected_value"
rows="3"
placeholder="e.g., 192.168.1.1 or mail.example.com">{{ old('expected_value') }}</textarea>
@error('expected_value')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">
Specify the expected value to validate against. Leave empty to only check if the record exists.
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="is_active"
name="is_active"
value="1"
{{ old('is_active', true) ? 'checked' : '' }}>
<label class="form-check-label" for="is_active">
Enable monitoring for this DNS record
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Add DNS Record
</button>
<a href="{{ route('dns-records.index', $site) }}" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-info-circle"></i> DNS Record Types</h5>
</div>
<div class="card-body">
<dl class="small mb-0">
<dt>A</dt>
<dd>Maps domain to IPv4 address</dd>
<dt>AAAA</dt>
<dd>Maps domain to IPv6 address</dd>
<dt>CNAME</dt>
<dd>Alias to another domain</dd>
<dt>MX</dt>
<dd>Mail server records</dd>
<dt>TXT</dt>
<dd>Text records (SPF, DKIM, etc.)</dd>
<dt>NS</dt>
<dd>Name server records</dd>
<dt>SOA</dt>
<dd>Start of authority</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-lightbulb"></i> Tips</h5>
</div>
<div class="card-body">
<ul class="small mb-0">
<li>DNS checks run hourly</li>
<li>Leave expected value empty to only verify record exists</li>
<li>For MX records, include priority (e.g., "10 mail.example.com")</li>
<li>Alerts are sent when records become invalid</li>
</ul>
</div>
</div>
</div>
</div>
@endsection
+142
View File
@@ -0,0 +1,142 @@
@extends('layouts.app')
@section('title', 'Edit DNS Record - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item"><a href="{{ route('dns-records.index', $site) }}">DNS Records</a></li>
<li class="breadcrumb-item active">Edit</li>
</ol>
</nav>
</div>
<div class="row">
<div class="col-md-8">
<h1 class="mb-4"><i class="bi bi-pencil"></i> Edit DNS Record</h1>
<div class="card">
<div class="card-body">
<form action="{{ route('dns-records.update', [$site, $dnsRecord]) }}" method="POST">
@csrf
@method('PUT')
<div class="mb-3">
<label for="domain" class="form-label">Domain Name</label>
<input type="text"
class="form-control @error('domain') is-invalid @enderror"
id="domain"
name="domain"
value="{{ old('domain', $dnsRecord->domain) }}"
required>
@error('domain')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Enter the domain name to monitor</div>
</div>
<div class="mb-3">
<label for="record_type" class="form-label">Record Type</label>
<select class="form-select @error('record_type') is-invalid @enderror"
id="record_type"
name="record_type"
required>
@foreach($recordTypes as $value => $label)
<option value="{{ $value }}" {{ old('record_type', $dnsRecord->record_type) == $value ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
@error('record_type')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Select the DNS record type to monitor</div>
</div>
<div class="mb-3">
<label for="expected_value" class="form-label">Expected Value (Optional)</label>
<textarea class="form-control @error('expected_value') is-invalid @enderror"
id="expected_value"
name="expected_value"
rows="3"
placeholder="e.g., 192.168.1.1 or mail.example.com">{{ old('expected_value', $dnsRecord->expected_value) }}</textarea>
@error('expected_value')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">
Specify the expected value to validate against. Leave empty to only check if the record exists.
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="is_active"
name="is_active"
value="1"
{{ old('is_active', $dnsRecord->is_active) ? 'checked' : '' }}>
<label class="form-check-label" for="is_active">
Enable monitoring for this DNS record
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update DNS Record
</button>
<a href="{{ route('dns-records.show', [$site, $dnsRecord]) }}" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-activity"></i> Current Status</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Status:</dt>
<dd class="col-sm-7">
<span class="badge bg-{{ $dnsRecord->status_color }}">
{{ $dnsRecord->status_text }}
</span>
</dd>
<dt class="col-sm-5">Last Checked:</dt>
<dd class="col-sm-7">
@if($dnsRecord->last_checked_at)
{{ $dnsRecord->last_checked_at->diffForHumans() }}
@else
<span class="text-muted">Never</span>
@endif
</dd>
@if($dnsRecord->formatted_actual_value)
<dt class="col-sm-5">Current Value:</dt>
<dd class="col-sm-7">
<small>{{ Str::limit($dnsRecord->formatted_actual_value, 40) }}</small>
</dd>
@endif
@if($dnsRecord->error_message)
<dt class="col-sm-5">Error:</dt>
<dd class="col-sm-7">
<small class="text-danger">{{ Str::limit($dnsRecord->error_message, 40) }}</small>
</dd>
@endif
</dl>
</div>
</div>
</div>
</div>
@endsection
+109
View File
@@ -0,0 +1,109 @@
@extends('layouts.app')
@section('title', 'DNS Records - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item active">DNS Records</li>
</ol>
</nav>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-hdd-network"></i> DNS Monitoring</h1>
<a href="{{ route('dns-records.create', $site) }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add DNS Record
</a>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
@if($dnsRecords->isEmpty())
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No DNS records are being monitored for this site yet. Click "Add DNS Record" to start monitoring.
</div>
@else
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Domain</th>
<th>Type</th>
<th>Status</th>
<th>Expected Value</th>
<th>Last Checked</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($dnsRecords as $record)
<tr>
<td><strong>{{ $record->domain }}</strong></td>
<td>
<span class="badge bg-info">{{ $record->record_type }}</span>
</td>
<td>
<span class="badge bg-{{ $record->status_color }}">
{{ $record->status_text }}
</span>
</td>
<td>
@if($record->expected_value)
<small class="text-muted">{{ Str::limit($record->expected_value, 30) }}</small>
@else
<span class="text-muted">—</span>
@endif
</td>
<td>
@if($record->last_checked_at)
{{ $record->last_checked_at->diffForHumans() }}
@else
<span class="text-muted">Never</span>
@endif
</td>
<td>
@if($record->is_active)
<span class="badge bg-success">Active</span>
@else
<span class="badge bg-secondary">Inactive</span>
@endif
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ route('dns-records.show', [$site, $record]) }}"
class="btn btn-outline-primary" title="View">
<i class="bi bi-eye"></i>
</a>
<a href="{{ route('dns-records.edit', [$site, $record]) }}"
class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ route('dns-records.destroy', [$site, $record]) }}"
method="POST"
class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this DNS record?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@endsection
+226
View File
@@ -0,0 +1,226 @@
@extends('layouts.app')
@section('title', 'DNS Record - ' . $dnsRecord->display_name . ' - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item"><a href="{{ route('dns-records.index', $site) }}">DNS Records</a></li>
<li class="breadcrumb-item active">{{ $dnsRecord->record_type }} - {{ $dnsRecord->domain }}</li>
</ol>
</nav>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-hdd-network"></i> {{ $dnsRecord->record_type }} - {{ $dnsRecord->domain }}</h1>
<div class="btn-group">
<a href="{{ route('dns-records.edit', [$site, $dnsRecord]) }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
<form action="{{ route('dns-records.destroy', [$site, $dnsRecord]) }}"
method="POST"
class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this DNS record?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-outline-danger">
<i class="bi bi-trash"></i> Delete
</button>
</form>
</div>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
<div class="row">
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">DNS Record Details</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Site</dt>
<dd class="col-sm-9">
<a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a>
<br>
<small class="text-muted">{{ $site->url }}</small>
</dd>
<dt class="col-sm-3">Domain</dt>
<dd class="col-sm-9"><strong>{{ $dnsRecord->domain }}</strong></dd>
<dt class="col-sm-3">Record Type</dt>
<dd class="col-sm-9">
<span class="badge bg-info">{{ $dnsRecord->record_type }}</span>
</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge bg-{{ $dnsRecord->status_color }}">
{{ $dnsRecord->status_text }}
</span>
</dd>
<dt class="col-sm-3">Monitoring</dt>
<dd class="col-sm-9">
@if($dnsRecord->is_active)
<span class="badge bg-success">Active</span>
@else
<span class="badge bg-secondary">Inactive</span>
@endif
</dd>
<dt class="col-sm-3">Last Checked</dt>
<dd class="col-sm-9">
@if($dnsRecord->last_checked_at)
{{ $dnsRecord->last_checked_at->format('Y-m-d H:i:s') }}
<br>
<small class="text-muted">{{ $dnsRecord->last_checked_at->diffForHumans() }}</small>
@else
<span class="text-muted">Never</span>
@endif
</dd>
<dt class="col-sm-3">Expected Value</dt>
<dd class="col-sm-9">
@if($dnsRecord->expected_value)
<code>{{ $dnsRecord->expected_value }}</code>
@else
<span class="text-muted">Not specified (only checking existence)</span>
@endif
</dd>
@if($dnsRecord->actual_value)
<dt class="col-sm-3">Actual Value(s)</dt>
<dd class="col-sm-9">
@if(is_array($dnsRecord->actual_value))
<ul class="mb-0">
@foreach($dnsRecord->actual_value as $value)
<li><code>{{ $value }}</code></li>
@endforeach
</ul>
@else
<code>{{ $dnsRecord->actual_value }}</code>
@endif
</dd>
@endif
@if($dnsRecord->error_message)
<dt class="col-sm-3">Error</dt>
<dd class="col-sm-9">
<div class="alert alert-danger mb-0">
<i class="bi bi-exclamation-triangle"></i> {{ $dnsRecord->error_message }}
</div>
</dd>
@endif
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">
{{ $dnsRecord->created_at->format('Y-m-d H:i:s') }}
<br>
<small class="text-muted">{{ $dnsRecord->created_at->diffForHumans() }}</small>
</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">About {{ $dnsRecord->record_type }} Records</h5>
</div>
<div class="card-body">
@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
<p class="mb-0">{{ $descriptions[$dnsRecord->record_type] ?? 'DNS record type for monitoring.' }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Current Status</h5>
</div>
<div class="card-body text-center">
<div class="mb-3">
<span class="badge bg-{{ $dnsRecord->status_color }} fs-5 px-4 py-2">
{{ $dnsRecord->status_text }}
</span>
</div>
@if($dnsRecord->is_valid === true)
<p class="text-success mb-0">
<i class="bi bi-check-circle-fill"></i> DNS record is valid
</p>
@elseif($dnsRecord->is_valid === false)
<p class="text-danger mb-0">
<i class="bi bi-x-circle-fill"></i> DNS record is invalid
</p>
@else
<p class="text-muted mb-0">
<i class="bi bi-question-circle"></i> Not yet checked
</p>
@endif
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Monitoring Info</h5>
</div>
<div class="card-body">
<p class="mb-2">
<i class="bi bi-clock"></i> <strong>Check Frequency:</strong> Hourly
</p>
<p class="mb-2">
<i class="bi bi-bell"></i> <strong>Alerts:</strong> Sent when DNS becomes invalid
</p>
<p class="mb-0">
<i class="bi bi-arrow-repeat"></i> <strong>Validation:</strong>
@if($dnsRecord->expected_value)
Matches expected value
@else
Record exists
@endif
</p>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Quick Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ route('dns-records.index', $site) }}" class="btn btn-outline-primary">
<i class="bi bi-list-ul"></i> View All DNS Records
</a>
<a href="{{ route('dashboard.show', $site) }}" class="btn btn-outline-secondary">
<i class="bi bi-bar-chart"></i> Site Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
@endsection
@@ -0,0 +1,141 @@
@extends('layouts.app')
@section('title', 'Add Health Check - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item"><a href="{{ route('health-checks.index', $site) }}">Health Checks</a></li>
<li class="breadcrumb-item active">Add Health Check</li>
</ol>
</nav>
</div>
<div class="row">
<div class="col-md-8">
<h1 class="mb-4"><i class="bi bi-plus-circle"></i> Add Health Check</h1>
<div class="card">
<div class="card-body">
<form action="{{ route('health-checks.store', $site) }}" method="POST">
@csrf
<div class="mb-3">
<label for="endpoint" class="form-label">Health Endpoint Path</label>
<div class="input-group">
<span class="input-group-text">{{ rtrim($site->url, '/') }}/</span>
<input type="text"
class="form-control @error('endpoint') is-invalid @enderror"
id="endpoint"
name="endpoint"
value="{{ old('endpoint') }}"
placeholder="health"
required>
@error('endpoint')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-text">Enter the path to your health check endpoint (e.g., "health", "api/health", "status").</div>
</div>
<div class="mb-3">
<label for="expected_json_keys" class="form-label">Expected JSON Keys (Optional)</label>
<input type="text"
class="form-control @error('expected_json_keys') is-invalid @enderror"
id="expected_json_keys"
name="expected_json_keys"
value="{{ old('expected_json_keys') }}"
placeholder="status, database, cache">
@error('expected_json_keys')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">
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").
</div>
</div>
<div class="mb-3">
<label for="performance_threshold" class="form-label">Performance Threshold (Optional)</label>
<div class="input-group">
<input type="number"
class="form-control @error('performance_threshold') is-invalid @enderror"
id="performance_threshold"
name="performance_threshold"
value="{{ old('performance_threshold') }}"
min="1"
max="60000"
placeholder="1000">
<span class="input-group-text">ms</span>
@error('performance_threshold')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-text">
Alert when response time exceeds this threshold in milliseconds. Leave empty to disable performance alerts.
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="is_active"
name="is_active"
value="1"
{{ old('is_active', true) ? 'checked' : '' }}>
<label class="form-check-label" for="is_active">
Enable monitoring for this health check
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Add Health Check
</button>
<a href="{{ route('health-checks.index', $site) }}" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-info-circle"></i> Health Check Guide</h5>
</div>
<div class="card-body">
<h6>What is a Health Check?</h6>
<p class="small">
A health check endpoint is a URL that returns information about your application's status, including database connectivity, cache availability, and other critical services.
</p>
<h6 class="mt-3">Common Endpoints</h6>
<ul class="small mb-0">
<li><code>/health</code></li>
<li><code>/api/health</code></li>
<li><code>/status</code></li>
<li><code>/healthz</code></li>
<li><code>/.well-known/health</code></li>
</ul>
<h6 class="mt-3">Response Format</h6>
<p class="small mb-0">
Your endpoint should return JSON with a 2xx status code when healthy. Example:
</p>
<pre class="small bg-light p-2 mt-2"><code>{
"status": "healthy",
"database": "ok",
"cache": "ok"
}</code></pre>
</div>
</div>
</div>
</div>
@endsection
@@ -0,0 +1,149 @@
@extends('layouts.app')
@section('title', 'Edit Health Check - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item"><a href="{{ route('health-checks.index', $site) }}">Health Checks</a></li>
<li class="breadcrumb-item active">Edit</li>
</ol>
</nav>
</div>
<div class="row">
<div class="col-md-8">
<h1 class="mb-4"><i class="bi bi-pencil"></i> Edit Health Check</h1>
<div class="card">
<div class="card-body">
<form action="{{ route('health-checks.update', [$site, $healthCheck]) }}" method="POST">
@csrf
@method('PUT')
<div class="mb-3">
<label for="endpoint" class="form-label">Health Endpoint Path</label>
<div class="input-group">
<span class="input-group-text">{{ rtrim($site->url, '/') }}/</span>
<input type="text"
class="form-control @error('endpoint') is-invalid @enderror"
id="endpoint"
name="endpoint"
value="{{ old('endpoint', $healthCheck->endpoint) }}"
required>
@error('endpoint')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-text">Enter the path to your health check endpoint.</div>
</div>
<div class="mb-3">
<label for="expected_json_keys" class="form-label">Expected JSON Keys (Optional)</label>
<input type="text"
class="form-control @error('expected_json_keys') is-invalid @enderror"
id="expected_json_keys"
name="expected_json_keys"
value="{{ old('expected_json_keys', $healthCheck->expected_json_keys ? implode(', ', $healthCheck->expected_json_keys) : '') }}"
placeholder="status, database, cache">
@error('expected_json_keys')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">
Comma-separated list of JSON keys that must be present in the response. Supports dot notation for nested keys.
</div>
</div>
<div class="mb-3">
<label for="performance_threshold" class="form-label">Performance Threshold (Optional)</label>
<div class="input-group">
<input type="number"
class="form-control @error('performance_threshold') is-invalid @enderror"
id="performance_threshold"
name="performance_threshold"
value="{{ old('performance_threshold', $healthCheck->performance_threshold) }}"
min="1"
max="60000"
placeholder="1000">
<span class="input-group-text">ms</span>
@error('performance_threshold')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-text">
Alert when response time exceeds this threshold in milliseconds. Leave empty to disable performance alerts.
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="is_active"
name="is_active"
value="1"
{{ old('is_active', $healthCheck->is_active) ? 'checked' : '' }}>
<label class="form-check-label" for="is_active">
Enable monitoring for this health check
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update Health Check
</button>
<a href="{{ route('health-checks.show', [$site, $healthCheck]) }}" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-activity"></i> Current Status</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5">Status:</dt>
<dd class="col-sm-7">
<span class="badge bg-{{ $healthCheck->status_color }}">
{{ $healthCheck->status_text }}
</span>
</dd>
<dt class="col-sm-5">Last Checked:</dt>
<dd class="col-sm-7">
@if($healthCheck->last_checked_at)
{{ $healthCheck->last_checked_at->diffForHumans() }}
@else
<span class="text-muted">Never</span>
@endif
</dd>
@if($healthCheck->status_code)
<dt class="col-sm-5">Status Code:</dt>
<dd class="col-sm-7">
<span class="badge bg-{{ $healthCheck->status_code < 300 ? 'success' : 'danger' }}">
{{ $healthCheck->status_code }}
</span>
</dd>
@endif
@if($healthCheck->response_time_ms)
<dt class="col-sm-5">Response Time:</dt>
<dd class="col-sm-7">{{ $healthCheck->response_time_ms }}ms</dd>
@endif
</dl>
</div>
</div>
</div>
</div>
@endsection
@@ -0,0 +1,123 @@
@extends('layouts.app')
@section('title', 'Health Checks - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item active">Health Checks</li>
</ol>
</nav>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-heart-pulse"></i> Health Check Monitoring</h1>
<a href="{{ route('health-checks.create', $site) }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Health Check
</a>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
@if($healthChecks->isEmpty())
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No health check endpoints are being monitored for this site yet. Click "Add Health Check" to start monitoring.
</div>
@else
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Endpoint</th>
<th>Status</th>
<th>Status Code</th>
<th>Response Time</th>
<th>Last Checked</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($healthChecks as $healthCheck)
<tr>
<td>
<strong>{{ $healthCheck->endpoint }}</strong>
@if($healthCheck->expected_json_keys)
<br>
<small class="text-muted">
<i class="bi bi-key"></i> Validates: {{ implode(', ', $healthCheck->expected_json_keys) }}
</small>
@endif
</td>
<td>
<span class="badge bg-{{ $healthCheck->status_color }}">
{{ $healthCheck->status_text }}
</span>
</td>
<td>
@if($healthCheck->status_code)
<span class="badge bg-{{ $healthCheck->status_code < 300 ? 'success' : 'danger' }}">
{{ $healthCheck->status_code }}
</span>
@else
<span class="text-muted">—</span>
@endif
</td>
<td>
@if($healthCheck->response_time_ms)
{{ $healthCheck->response_time_ms }}ms
@else
<span class="text-muted">—</span>
@endif
</td>
<td>
@if($healthCheck->last_checked_at)
{{ $healthCheck->last_checked_at->diffForHumans() }}
@else
<span class="text-muted">Never</span>
@endif
</td>
<td>
@if($healthCheck->is_active)
<span class="badge bg-success">Active</span>
@else
<span class="badge bg-secondary">Inactive</span>
@endif
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ route('health-checks.show', [$site, $healthCheck]) }}"
class="btn btn-outline-primary" title="View">
<i class="bi bi-eye"></i>
</a>
<a href="{{ route('health-checks.edit', [$site, $healthCheck]) }}"
class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ route('health-checks.destroy', [$site, $healthCheck]) }}"
method="POST"
class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this health check?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@endsection
@@ -0,0 +1,231 @@
@extends('layouts.app')
@section('title', 'Health Check - ' . $healthCheck->endpoint . ' - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item"><a href="{{ route('health-checks.index', $site) }}">Health Checks</a></li>
<li class="breadcrumb-item active">{{ $healthCheck->endpoint }}</li>
</ol>
</nav>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-heart-pulse"></i> {{ $healthCheck->endpoint }}</h1>
<div class="btn-group">
<a href="{{ route('health-checks.edit', [$site, $healthCheck]) }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
<form action="{{ route('health-checks.destroy', [$site, $healthCheck]) }}"
method="POST"
class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this health check?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-outline-danger">
<i class="bi bi-trash"></i> Delete
</button>
</form>
</div>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
<div class="row">
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Health Check Details</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Site</dt>
<dd class="col-sm-9">
<a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a>
<br>
<small class="text-muted">{{ $site->url }}</small>
</dd>
<dt class="col-sm-3">Endpoint URL</dt>
<dd class="col-sm-9">
<a href="{{ $healthCheck->full_url }}" target="_blank" rel="noopener">
{{ $healthCheck->full_url }}
<i class="bi bi-box-arrow-up-right"></i>
</a>
</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge bg-{{ $healthCheck->status_color }}">
{{ $healthCheck->status_text }}
</span>
</dd>
<dt class="col-sm-3">Monitoring</dt>
<dd class="col-sm-9">
@if($healthCheck->is_active)
<span class="badge bg-success">Active</span>
@else
<span class="badge bg-secondary">Inactive</span>
@endif
</dd>
<dt class="col-sm-3">Last Checked</dt>
<dd class="col-sm-9">
@if($healthCheck->last_checked_at)
{{ $healthCheck->last_checked_at->format('Y-m-d H:i:s') }}
<br>
<small class="text-muted">{{ $healthCheck->last_checked_at->diffForHumans() }}</small>
@else
<span class="text-muted">Never</span>
@endif
</dd>
@if($healthCheck->status_code)
<dt class="col-sm-3">Status Code</dt>
<dd class="col-sm-9">
<span class="badge bg-{{ $healthCheck->status_code < 300 ? 'success' : 'danger' }}">
{{ $healthCheck->status_code }}
</span>
</dd>
@endif
@if($healthCheck->response_time_ms)
<dt class="col-sm-3">Response Time</dt>
<dd class="col-sm-9">{{ $healthCheck->response_time_ms }}ms</dd>
@endif
<dt class="col-sm-3">Expected Keys</dt>
<dd class="col-sm-9">
@if($healthCheck->expected_json_keys && count($healthCheck->expected_json_keys) > 0)
@foreach($healthCheck->expected_json_keys as $key)
<span class="badge bg-info text-dark me-1">{{ $key }}</span>
@endforeach
@else
<span class="text-muted">None specified</span>
@endif
</dd>
<dt class="col-sm-3">Performance Threshold</dt>
<dd class="col-sm-9">
@if($healthCheck->performance_threshold)
<span class="badge bg-warning text-dark">{{ $healthCheck->performance_threshold }}ms</span>
@if($healthCheck->response_time_ms && $healthCheck->response_time_ms > $healthCheck->performance_threshold)
<br><small class="text-danger"><i class="bi bi-exclamation-triangle"></i> Currently exceeding threshold</small>
@endif
@else
<span class="text-muted">Not set</span>
@endif
</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">
{{ $healthCheck->created_at->format('Y-m-d H:i:s') }}
<br>
<small class="text-muted">{{ $healthCheck->created_at->diffForHumans() }}</small>
</dd>
</dl>
</div>
</div>
@if($healthCheck->response_data)
<div class="card">
<div class="card-header">
<h5 class="mb-0">Latest Response Data</h5>
</div>
<div class="card-body">
@if(isset($healthCheck->response_data['error']))
<div class="alert alert-danger mb-0">
<i class="bi bi-exclamation-triangle"></i>
<strong>Error:</strong> {{ $healthCheck->response_data['error'] }}
</div>
@else
<pre class="bg-light p-3 rounded mb-0"><code>{{ json_encode($healthCheck->response_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</code></pre>
@endif
</div>
</div>
@endif
</div>
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Current Status</h5>
</div>
<div class="card-body text-center">
<div class="mb-3">
<span class="badge bg-{{ $healthCheck->status_color }} fs-5 px-4 py-2">
{{ $healthCheck->status_text }}
</span>
</div>
@if($healthCheck->is_healthy === true)
<p class="text-success mb-0">
<i class="bi bi-check-circle-fill"></i> All checks passed
</p>
@elseif($healthCheck->is_healthy === false)
<p class="text-danger mb-0">
<i class="bi bi-x-circle-fill"></i> Health check failed
</p>
@else
<p class="text-muted mb-0">
<i class="bi bi-question-circle"></i> Not yet checked
</p>
@endif
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Monitoring Info</h5>
</div>
<div class="card-body">
<p class="mb-2">
<i class="bi bi-clock"></i> <strong>Check Frequency:</strong> Every 5 minutes
</p>
<p class="mb-2">
<i class="bi bi-check2-square"></i> <strong>Requirements:</strong>
</p>
<ul class="small mb-0">
<li>2xx HTTP status code</li>
<li>Valid JSON response</li>
@if($healthCheck->expected_json_keys && count($healthCheck->expected_json_keys) > 0)
<li>Contains required keys: {{ implode(', ', $healthCheck->expected_json_keys) }}</li>
@endif
@if($healthCheck->performance_threshold)
<li class="text-warning">Performance alert if exceeds {{ $healthCheck->performance_threshold }}ms</li>
@endif
</ul>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Quick Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ route('health-checks.index', $site) }}" class="btn btn-outline-primary">
<i class="bi bi-list-ul"></i> View All Health Checks
</a>
<a href="{{ route('dashboard.show', $site) }}" class="btn btn-outline-secondary">
<i class="bi bi-bar-chart"></i> Site Dashboard
</a>
<a href="{{ $healthCheck->full_url }}" target="_blank" rel="noopener" class="btn btn-outline-info">
<i class="bi bi-box-arrow-up-right"></i> Open Endpoint
</a>
</div>
</div>
</div>
</div>
</div>
@endsection
+118
View File
@@ -0,0 +1,118 @@
@extends('layouts.app')
@section('title', 'Add TCP Port - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item"><a href="{{ route('tcp-ports.index', $site) }}">TCP Ports</a></li>
<li class="breadcrumb-item active">Add Port</li>
</ol>
</nav>
</div>
<div class="row">
<div class="col-md-8">
<h1 class="mb-4"><i class="bi bi-plus-circle"></i> Add TCP Port</h1>
<div class="card">
<div class="card-body">
<form action="{{ route('tcp-ports.store', $site) }}" method="POST">
@csrf
<div class="mb-3">
<label for="port" class="form-label">Port Number</label>
<input type="number"
class="form-control @error('port') is-invalid @enderror"
id="port"
name="port"
min="1"
max="65535"
value="{{ old('port') }}"
required>
@error('port')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Enter a port number between 1 and 65535.</div>
</div>
<div class="mb-3">
<label for="name" class="form-label">Port Name (Optional)</label>
<input type="text"
class="form-control @error('name') is-invalid @enderror"
id="name"
name="name"
value="{{ old('name') }}"
maxlength="255"
placeholder="e.g., Web Server, Database">
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Optional custom name for this port.</div>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="is_active"
name="is_active"
value="1"
{{ old('is_active', true) ? 'checked' : '' }}>
<label class="form-check-label" for="is_active">
Enable monitoring for this port
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Add Port
</button>
<a href="{{ route('tcp-ports.index', $site) }}" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Common Ports</h5>
</div>
<div class="card-body">
<small class="text-muted">Click to use:</small>
<div class="list-group list-group-flush mt-2">
@foreach($commonPorts as $portNumber => $portName)
<a href="#" class="list-group-item list-group-item-action py-2 px-3 common-port"
data-port="{{ $portNumber }}"
data-name="{{ $portName }}">
<strong>{{ $portNumber }}</strong> - {{ $portName }}
</a>
@endforeach
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.common-port').forEach(function(element) {
element.addEventListener('click', function(e) {
e.preventDefault();
document.getElementById('port').value = this.dataset.port;
document.getElementById('name').value = this.dataset.name;
});
});
});
</script>
@endpush
@endsection
+151
View File
@@ -0,0 +1,151 @@
@extends('layouts.app')
@section('title', 'Edit TCP Port - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item"><a href="{{ route('tcp-ports.index', $site) }}">TCP Ports</a></li>
<li class="breadcrumb-item active">Edit Port {{ $tcpPort->port }}</li>
</ol>
</nav>
</div>
<div class="row">
<div class="col-md-8">
<h1 class="mb-4"><i class="bi bi-pencil"></i> Edit TCP Port</h1>
<div class="card">
<div class="card-body">
<form action="{{ route('tcp-ports.update', [$site, $tcpPort]) }}" method="POST">
@csrf
@method('PUT')
<div class="mb-3">
<label for="port" class="form-label">Port Number</label>
<input type="number"
class="form-control @error('port') is-invalid @enderror"
id="port"
name="port"
min="1"
max="65535"
value="{{ old('port', $tcpPort->port) }}"
required>
@error('port')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Enter a port number between 1 and 65535.</div>
</div>
<div class="mb-3">
<label for="name" class="form-label">Port Name (Optional)</label>
<input type="text"
class="form-control @error('name') is-invalid @enderror"
id="name"
name="name"
value="{{ old('name', $tcpPort->name) }}"
maxlength="255"
placeholder="e.g., Web Server, Database">
@error('name')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
<div class="form-text">Optional custom name for this port.</div>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="is_active"
name="is_active"
value="1"
{{ old('is_active', $tcpPort->is_active) ? 'checked' : '' }}>
<label class="form-check-label" for="is_active">
Enable monitoring for this port
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Update Port
</button>
<a href="{{ route('tcp-ports.index', $site) }}" class="btn btn-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Port Status</h5>
</div>
<div class="card-body">
<dl class="mb-0">
<dt>Current Status</dt>
<dd>
<span class="badge bg-{{ $tcpPort->status_color }}">
{{ $tcpPort->status_text }}
</span>
</dd>
<dt>Last Checked</dt>
<dd>
@if($tcpPort->last_checked_at)
{{ $tcpPort->last_checked_at->format('Y-m-d H:i:s') }}
<br>
<small class="text-muted">{{ $tcpPort->last_checked_at->diffForHumans() }}</small>
@else
<span class="text-muted">Never</span>
@endif
</dd>
@if($tcpPort->response_time_ms)
<dt>Response Time</dt>
<dd>{{ $tcpPort->response_time_ms }}ms</dd>
@endif
</dl>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Common Ports</h5>
</div>
<div class="card-body">
<small class="text-muted">Click to use:</small>
<div class="list-group list-group-flush mt-2" style="max-height: 300px; overflow-y: auto;">
@foreach($commonPorts as $portNumber => $portName)
<a href="#" class="list-group-item list-group-item-action py-2 px-3 common-port"
data-port="{{ $portNumber }}"
data-name="{{ $portName }}">
<strong>{{ $portNumber }}</strong> - {{ $portName }}
</a>
@endforeach
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.common-port').forEach(function(element) {
element.addEventListener('click', function(e) {
e.preventDefault();
document.getElementById('port').value = this.dataset.port;
document.getElementById('name').value = this.dataset.name;
});
});
});
</script>
@endpush
@endsection
+109
View File
@@ -0,0 +1,109 @@
@extends('layouts.app')
@section('title', 'TCP Ports - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item active">TCP Ports</li>
</ol>
</nav>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-ethernet"></i> TCP Port Monitoring</h1>
<a href="{{ route('tcp-ports.create', $site) }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Port
</a>
</div>
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show">
{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
@if($tcpPorts->isEmpty())
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No TCP ports are being monitored for this site yet. Click "Add Port" to start monitoring.
</div>
@else
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Port</th>
<th>Name</th>
<th>Status</th>
<th>Response Time</th>
<th>Last Checked</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($tcpPorts as $port)
<tr>
<td><strong>{{ $port->port }}</strong></td>
<td>{{ $port->display_name }}</td>
<td>
<span class="badge bg-{{ $port->status_color }}">
{{ $port->status_text }}
</span>
</td>
<td>
@if($port->response_time_ms)
{{ $port->response_time_ms }}ms
@else
<span class="text-muted">—</span>
@endif
</td>
<td>
@if($port->last_checked_at)
{{ $port->last_checked_at->diffForHumans() }}
@else
<span class="text-muted">Never</span>
@endif
</td>
<td>
@if($port->is_active)
<span class="badge bg-success">Active</span>
@else
<span class="badge bg-secondary">Inactive</span>
@endif
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ route('tcp-ports.show', [$site, $port]) }}"
class="btn btn-outline-primary" title="View">
<i class="bi bi-eye"></i>
</a>
<a href="{{ route('tcp-ports.edit', [$site, $port]) }}"
class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ route('tcp-ports.destroy', [$site, $port]) }}"
method="POST"
class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this port monitor?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{ $tcpPorts->links() }}
@endif
@endsection
+168
View File
@@ -0,0 +1,168 @@
@extends('layouts.app')
@section('title', 'TCP Port ' . $tcpPort->port . ' - ' . $site->name)
@section('content')
<div class="mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ route('sites.index') }}">Sites</a></li>
<li class="breadcrumb-item"><a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a></li>
<li class="breadcrumb-item"><a href="{{ route('tcp-ports.index', $site) }}">TCP Ports</a></li>
<li class="breadcrumb-item active">Port {{ $tcpPort->port }}</li>
</ol>
</nav>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-ethernet"></i> Port {{ $tcpPort->port }} - {{ $tcpPort->display_name }}</h1>
<div class="btn-group">
<a href="{{ route('tcp-ports.edit', [$site, $tcpPort]) }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
<form action="{{ route('tcp-ports.destroy', [$site, $tcpPort]) }}"
method="POST"
class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this port monitor?');">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-outline-danger">
<i class="bi bi-trash"></i> Delete
</button>
</form>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Port Details</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Site</dt>
<dd class="col-sm-9">
<a href="{{ route('dashboard.show', $site) }}">{{ $site->name }}</a>
<br>
<small class="text-muted">{{ $site->url }}</small>
</dd>
<dt class="col-sm-3">Port Number</dt>
<dd class="col-sm-9"><strong>{{ $tcpPort->port }}</strong></dd>
<dt class="col-sm-3">Port Name</dt>
<dd class="col-sm-9">{{ $tcpPort->display_name }}</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge bg-{{ $tcpPort->status_color }}">
{{ $tcpPort->status_text }}
</span>
</dd>
<dt class="col-sm-3">Monitoring</dt>
<dd class="col-sm-9">
@if($tcpPort->is_active)
<span class="badge bg-success">Active</span>
@else
<span class="badge bg-secondary">Inactive</span>
@endif
</dd>
<dt class="col-sm-3">Last Checked</dt>
<dd class="col-sm-9">
@if($tcpPort->last_checked_at)
{{ $tcpPort->last_checked_at->format('Y-m-d H:i:s') }}
<br>
<small class="text-muted">{{ $tcpPort->last_checked_at->diffForHumans() }}</small>
@else
<span class="text-muted">Never</span>
@endif
</dd>
@if($tcpPort->response_time_ms)
<dt class="col-sm-3">Response Time</dt>
<dd class="col-sm-9">{{ $tcpPort->response_time_ms }}ms</dd>
@endif
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">
{{ $tcpPort->created_at->format('Y-m-d H:i:s') }}
<br>
<small class="text-muted">{{ $tcpPort->created_at->diffForHumans() }}</small>
</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">About Port {{ $tcpPort->port }}</h5>
</div>
<div class="card-body">
@php
$commonPorts = \App\Models\TcpPort::getCommonPorts();
$portInfo = $commonPorts[$tcpPort->port] ?? null;
@endphp
@if($portInfo)
<p class="mb-2">
<strong>Common Service:</strong> {{ $portInfo }}
</p>
@endif
<p class="text-muted mb-0">
This monitor checks if port {{ $tcpPort->port }} is accessible on {{ $site->url }}.
Checks run every 10 minutes when monitoring is active.
</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Current Status</h5>
</div>
<div class="card-body text-center">
<div class="mb-3">
<span class="badge bg-{{ $tcpPort->status_color }} fs-5 px-4 py-2">
{{ $tcpPort->status_text }}
</span>
</div>
@if($tcpPort->is_open)
<p class="text-success mb-0">
<i class="bi bi-check-circle-fill"></i> Port is accessible
</p>
@elseif($tcpPort->is_open === false)
<p class="text-danger mb-0">
<i class="bi bi-x-circle-fill"></i> Port is not accessible
</p>
@else
<p class="text-muted mb-0">
<i class="bi bi-question-circle"></i> Not yet checked
</p>
@endif
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Quick Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ route('tcp-ports.index', $site) }}" class="btn btn-outline-primary">
<i class="bi bi-list-ul"></i> View All Ports
</a>
<a href="{{ route('dashboard.show', $site) }}" class="btn btn-outline-secondary">
<i class="bi bi-bar-chart"></i> Site Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
@endsection
+48
View File
@@ -0,0 +1,48 @@
<?php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\LinkController;
use App\Http\Controllers\Api\SiteController;
use App\Http\Controllers\Api\SslController;
use App\Http\Controllers\Api\UptimeController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/
Route::get('/test', function (Request $request) {
return response()->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']);
});
+28
View File
@@ -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'));
+7
View File
@@ -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');