From 7fa6924b41de45045b35a302c3ee283bf3871b27 Mon Sep 17 00:00:00 2001 From: Sari Yono Date: Wed, 1 Oct 2025 20:26:29 -0400 Subject: [PATCH] added some upgrades --- .github/workflows/ci.yml | 62 +++++++ README.md | 104 +++++++++++ app/Console/Commands/RunDueMonitors.php | 4 +- .../MaintenanceWindowController.php | 71 ++++++++ app/Http/Controllers/MonitorController.php | 130 ++++++++++++-- .../Controllers/MonitorPolicyController.php | 92 ++++++++++ .../Controllers/MonitorWebhookController.php | 56 ++++++ .../NotificationChannelController.php | 68 ++++++++ app/Http/Controllers/StatusPageController.php | 57 ++++++ app/Jobs/CheckMonitor.php | 137 +++------------ app/Models/MaintenanceWindow.php | 34 ++++ app/Models/Monitor.php | 39 ++++- app/Models/MonitorCheck.php | 5 +- app/Models/MonitorPolicy.php | 39 +++++ app/Models/NotificationChannel.php | 33 ++++ app/Providers/AppServiceProvider.php | 10 +- app/Services/Alerting/Notifier.php | 104 +++++++++++ .../Monitoring/Drivers/HttpMonitorDriver.php | 130 ++++++++++++++ .../Monitoring/Drivers/PingMonitorDriver.php | 89 ++++++++++ .../Drivers/SslCertificateMonitorDriver.php | 77 +++++++++ .../Monitoring/Drivers/TcpMonitorDriver.php | 40 +++++ .../Drivers/WebhookMonitorDriver.php | 16 ++ app/Services/Monitoring/MonitorDriver.php | 11 ++ .../Monitoring/MonitorDriverManager.php | 46 +++++ app/Services/Monitoring/MonitorResult.php | 80 +++++++++ .../Monitoring/MonitorStateService.php | 79 +++++++++ bootstrap/app.php | 1 + database/factories/MonitorFactory.php | 55 ++++++ ...000_create_notification_channels_table.php | 27 +++ ...4_130100_create_monitor_policies_table.php | 37 ++++ ...30200_create_maintenance_windows_table.php | 30 ++++ ...0_01_190348_extend_monitors_and_checks.php | 33 ++++ resources/js/components/AppSidebar.vue | 17 +- resources/js/pages/Alerts/Channels/Create.vue | 70 ++++++++ resources/js/pages/Alerts/Channels/Edit.vue | 71 ++++++++ resources/js/pages/Alerts/Channels/Index.vue | 56 ++++++ resources/js/pages/Docs/WebhooksMonitors.vue | 151 ++++++++++++++++ resources/js/pages/Maintenance/Create.vue | 65 +++++++ resources/js/pages/Maintenance/Edit.vue | 66 +++++++ resources/js/pages/Maintenance/Index.vue | 55 ++++++ resources/js/pages/Monitors/Create.vue | 153 +++++++++++++++- resources/js/pages/Monitors/Edit.vue | 163 +++++++++++++++++- resources/js/pages/Monitors/Index.vue | 41 ++++- .../js/pages/Monitors/Policies/Create.vue | 70 ++++++++ resources/js/pages/Monitors/Policies/Edit.vue | 70 ++++++++ .../js/pages/Monitors/Policies/Index.vue | 56 ++++++ resources/js/pages/Monitors/Show.vue | 111 ++++++++++-- resources/js/pages/Status/Index.vue | 74 ++++++++ routes/api.php | 3 + routes/web.php | 18 ++ tests/Feature/Monitors/MonitorWebhookTest.php | 62 +++++++ 51 files changed, 3007 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md create mode 100644 app/Http/Controllers/MaintenanceWindowController.php create mode 100644 app/Http/Controllers/MonitorPolicyController.php create mode 100644 app/Http/Controllers/MonitorWebhookController.php create mode 100644 app/Http/Controllers/NotificationChannelController.php create mode 100644 app/Http/Controllers/StatusPageController.php create mode 100644 app/Models/MaintenanceWindow.php create mode 100644 app/Models/MonitorPolicy.php create mode 100644 app/Models/NotificationChannel.php create mode 100644 app/Services/Alerting/Notifier.php create mode 100644 app/Services/Monitoring/Drivers/HttpMonitorDriver.php create mode 100644 app/Services/Monitoring/Drivers/PingMonitorDriver.php create mode 100644 app/Services/Monitoring/Drivers/SslCertificateMonitorDriver.php create mode 100644 app/Services/Monitoring/Drivers/TcpMonitorDriver.php create mode 100644 app/Services/Monitoring/Drivers/WebhookMonitorDriver.php create mode 100644 app/Services/Monitoring/MonitorDriver.php create mode 100644 app/Services/Monitoring/MonitorDriverManager.php create mode 100644 app/Services/Monitoring/MonitorResult.php create mode 100644 app/Services/Monitoring/MonitorStateService.php create mode 100644 database/factories/MonitorFactory.php create mode 100644 database/migrations/2025_09_14_130000_create_notification_channels_table.php create mode 100644 database/migrations/2025_09_14_130100_create_monitor_policies_table.php create mode 100644 database/migrations/2025_09_14_130200_create_maintenance_windows_table.php create mode 100644 database/migrations/2025_10_01_190348_extend_monitors_and_checks.php create mode 100644 resources/js/pages/Alerts/Channels/Create.vue create mode 100644 resources/js/pages/Alerts/Channels/Edit.vue create mode 100644 resources/js/pages/Alerts/Channels/Index.vue create mode 100644 resources/js/pages/Docs/WebhooksMonitors.vue create mode 100644 resources/js/pages/Maintenance/Create.vue create mode 100644 resources/js/pages/Maintenance/Edit.vue create mode 100644 resources/js/pages/Maintenance/Index.vue create mode 100644 resources/js/pages/Monitors/Policies/Create.vue create mode 100644 resources/js/pages/Monitors/Policies/Edit.vue create mode 100644 resources/js/pages/Monitors/Policies/Index.vue create mode 100644 resources/js/pages/Status/Index.vue create mode 100644 tests/Feature/Monitors/MonitorWebhookTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2a61e67 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: ["main", "master"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring, curl, dom, pdo_sqlite, fileinfo + tools: composer + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Cache Composer dependencies + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Restore Composer cache + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install Composer dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Copy environment file + run: cp .env.example .env + + - name: Generate application key + run: php artisan key:generate + + - name: Prepare SQLite database + run: php -r "file_exists('database/database.sqlite') || touch('database/database.sqlite');" + + - name: Install Node dependencies + run: npm ci + + - name: Build assets + run: npm run build + + - name: Run test suite + env: + DB_CONNECTION: sqlite + DB_DATABASE: database/database.sqlite + run: composer test diff --git a/README.md b/README.md new file mode 100644 index 0000000..6dd053b --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# MSP Monitor + +MSP Monitor is a Laravel + Vue application built for managed service providers to track +infrastructure health across data warehouses, Spectrum servers, customer websites, SSL +certificates, and third-party telemetry delivered via webhooks. Checks run on a schedule +through dedicated drivers (HTTP, ICMP ping, TCP port, SSL certificate) with alert routing, +maintenance windows, and public status pages baked in. + +## Key Capabilities +- Multi-protocol monitoring with driver architecture (HTTP, ping, TCP, SSL, webhook ingest) +- Per-monitor policies and notification channels (email, Slack webhooks) +- Maintenance windows to suppress noise during planned work +- UniFi Site Manager integration for automated device sync +- Public-facing status page support via Inertia/SPA frontend +- Queue-driven execution and centralized incident tracking + +## Requirements +- PHP **8.2** or newer with required extensions (`bcmath`, `ctype`, `openssl`, `pdo`, `mbstring`, `curl`) +- Composer **2.x** +- Node.js **18+** and npm (or pnpm/yarn) for frontend assets +- Redis, SQS, or database queue driver (database queue enabled out of the box) +- A SQL database (MySQL 8+, MariaDB 10.5+, or PostgreSQL 13+) +- Supervisor/Process manager for long-running queue workers and scheduled commands + +Optional: +- Access to UniFi Site Manager API (configure `UNIFI_SITE_MANAGER_BASE_URL` and `UNIFI_SITE_MANAGER_API_KEY`) +- Mail provider credentials for outbound alerts and user notifications + +## First-Time Setup +1. **Clone & Install Dependencies** + ```bash + git clone + cd monitor + composer install --no-interaction --prefer-dist + npm install + ``` + +2. **Configure Environment** + ```bash + cp .env.example .env + php artisan key:generate + ``` + Update `.env` with database credentials, queue driver, mail settings, and any optional + integrations (Slack, UniFi, etc.). + +3. **Run Migrations** + ```bash + php artisan migrate --force + ``` + +4. **Build Frontend Assets** (development) + ```bash + npm run dev + ``` + For production: `npm run build` (or `npm run build && npm run build:ssr` if SSR is used). + +## Local Development +- Launch the bundled dev stack: `composer run dev` + - Serves the app, queue listener, log tailing, and Vite via `concurrently`. +- Alternatively run services individually: + ```bash + php artisan serve + php artisan queue:listen --tries=1 + npm run dev + ``` +- Execute the scheduler locally with: `php artisan schedule:work` + +## Deployment Guide +1. **Install PHP Dependencies** on the target host (as in setup) using `composer install --no-dev --optimize-autoloader`. +2. **Install Frontend Assets** via `npm ci && npm run build` from the release directory. +3. **Set Environment** variables and ensure `.env` has production credentials. +4. **Run Database Migrations** during each deploy: `php artisan migrate --force`. +5. **Cache Optimizations** (optional but recommended): + ```bash + php artisan config:cache + php artisan route:cache + php artisan view:cache + ``` +6. **Queue Worker**: supervise `php artisan queue:work --sleep=1 --tries=3` (or use Horizon). +7. **Scheduler**: configure cron to run every minute: + ```cron + * * * * * php /path/to/artisan schedule:run >> /dev/null 2>&1 + ``` + This dispatches monitor checks (`monitors:run`) and UniFi sync jobs. +8. **Permissions**: grant web server user write access to `storage/` and `bootstrap/cache/`. + +## Monitoring & Maintenance +- Manual ad-hoc monitor run: `php artisan monitors:run` or POST to `/monitors/{id}/run` in the UI. +- Webhook monitor ingestion endpoint: `POST /api/webhooks/monitors/{token}` with JSON payload. +- Queue health: tail `storage/logs/laravel.log` and `storage/logs/schedule.log` for job/scheduler output. + +## Testing +```bash +php artisan test # Runs full Pest test suite +``` + +CI/CD pipelines should execute the test suite and a linter/formatter (e.g., `vendor/bin/pint`) prior to deploys. + +## Additional Notes +- Configure trusted proxies/load balancers via `App\Http\Middleware\TrustProxies` if deploying behind a proxy. +- For SSL/TLS probing support ensure the host running checks can establish outbound connections + (ICMP, TCP, HTTP/HTTPS as needed). +- When using the ping driver on Linux, grant the PHP process permission to run `ping` (setcap or wrapper). + diff --git a/app/Console/Commands/RunDueMonitors.php b/app/Console/Commands/RunDueMonitors.php index 79e2279..9803aea 100644 --- a/app/Console/Commands/RunDueMonitors.php +++ b/app/Console/Commands/RunDueMonitors.php @@ -14,7 +14,9 @@ class RunDueMonitors extends Command public function handle(): int { $now = now(); - $monitors = Monitor::all(); + $monitors = Monitor::query() + ->where('type', '!=', 'webhook') + ->get(); $due = $monitors->filter(function ($m) use ($now) { if (empty($m->frequency_minutes) || $m->frequency_minutes < 1) return false; if (is_null($m->last_checked_at)) return true; diff --git a/app/Http/Controllers/MaintenanceWindowController.php b/app/Http/Controllers/MaintenanceWindowController.php new file mode 100644 index 0000000..98b1ec4 --- /dev/null +++ b/app/Http/Controllers/MaintenanceWindowController.php @@ -0,0 +1,71 @@ +get(); + return Inertia::render('Maintenance/Index', [ 'windows' => $windows ]); + } + + public function create() + { + return Inertia::render('Maintenance/Create', [ + 'organizations' => Organization::orderBy('name')->get(['id','name']), + 'devices' => Device::orderBy('name')->get(['id','name']), + 'monitors' => Monitor::orderBy('name')->get(['id','name']), + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'scope' => ['required','in:global,organization,device,monitor'], + 'ref_id' => ['nullable','integer'], + 'start_at' => ['required','date'], + 'end_at' => ['required','date','after:start_at'], + 'reason' => ['nullable','string','max:255'], + ]); + MaintenanceWindow::create($data); + return redirect()->route('maintenance.index')->with('success','Maintenance window created.'); + } + + public function edit(MaintenanceWindow $maintenance) + { + return Inertia::render('Maintenance/Edit', [ + 'maintenance' => $maintenance, + 'organizations' => Organization::orderBy('name')->get(['id','name']), + 'devices' => Device::orderBy('name')->get(['id','name']), + 'monitors' => Monitor::orderBy('name')->get(['id','name']), + ]); + } + + public function update(Request $request, MaintenanceWindow $maintenance) + { + $data = $request->validate([ + 'scope' => ['required','in:global,organization,device,monitor'], + 'ref_id' => ['nullable','integer'], + 'start_at' => ['required','date'], + 'end_at' => ['required','date','after:start_at'], + 'reason' => ['nullable','string','max:255'], + ]); + $maintenance->update($data); + return redirect()->route('maintenance.index')->with('success','Maintenance window updated.'); + } + + public function destroy(MaintenanceWindow $maintenance) + { + $maintenance->delete(); + return redirect()->route('maintenance.index')->with('success','Maintenance window deleted.'); + } +} + diff --git a/app/Http/Controllers/MonitorController.php b/app/Http/Controllers/MonitorController.php index b77ac2f..9ef0109 100644 --- a/app/Http/Controllers/MonitorController.php +++ b/app/Http/Controllers/MonitorController.php @@ -4,7 +4,9 @@ use App\Jobs\CheckMonitor; use App\Models\Monitor; +use Illuminate\Support\Arr; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Inertia\Inertia; class MonitorController extends Controller @@ -23,20 +25,31 @@ public function create() return Inertia::render('Monitors/Create'); } + private const MONITOR_TYPES = ['http', 'ping', 'tcp', 'ssl', 'webhook']; + public function store(Request $request) { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'url' => ['required', 'url', 'max:2048'], - 'frequency_minutes' => ['required', 'integer', 'min:1', 'max:1440'], + $validated = $this->validateMonitor($request); + + [$target, $config] = $this->extractTargetAndConfig($validated); + + $monitor = Monitor::create([ + 'name' => $validated['name'], + 'type' => $validated['type'], + 'url' => $target, + 'config' => $config, + 'frequency_minutes' => $validated['frequency_minutes'] ?? 5, ]); - $monitor = Monitor::create($data); + if ($monitor->type !== 'webhook') { + CheckMonitor::dispatch($monitor); + } - // Kick off first check - CheckMonitor::dispatch($monitor); + $message = $monitor->type === 'webhook' + ? 'Webhook monitor created. Token generated.' + : 'Monitor created. First check queued.'; - return redirect()->route('monitors.index')->with('success', 'Monitor created. First check queued.'); + return redirect()->route('monitors.index')->with('success', $message); } public function show(Monitor $monitor) @@ -54,12 +67,18 @@ public function edit(Monitor $monitor) public function update(Request $request, Monitor $monitor) { - $data = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'url' => ['required', 'url', 'max:2048'], - 'frequency_minutes' => ['required', 'integer', 'min:1', 'max:1440'], + $validated = $this->validateMonitor($request, $monitor); + + [$target, $config] = $this->extractTargetAndConfig($validated); + + $monitor->update([ + 'name' => $validated['name'], + 'type' => $validated['type'], + 'url' => $target, + 'config' => $config, + 'frequency_minutes' => $validated['frequency_minutes'] ?? $monitor->frequency_minutes, ]); - $monitor->update($data); + return redirect()->route('monitors.index')->with('success', 'Monitor updated.'); } @@ -72,7 +91,92 @@ public function destroy(Monitor $monitor) public function run(Monitor $monitor) { // Queue in background; requires a running queue worker + if ($monitor->type === 'webhook') { + return back()->with('info', 'Webhook monitors ingest via external calls.'); + } + CheckMonitor::dispatch($monitor); return back()->with('success', 'Monitor check queued.'); } + + private function validateMonitor(Request $request, ?Monitor $monitor = null): array + { + $type = $request->input('type', $monitor?->type ?? 'http'); + + $portRules = ['nullable', 'integer', 'between:1,65535']; + if (in_array($type, ['tcp', 'ssl'], true)) { + $portRules[0] = 'required'; + } + + $frequencyRules = ['integer', 'min:1', 'max:1440']; + if ($type === 'webhook') { + array_unshift($frequencyRules, 'nullable'); + } else { + array_unshift($frequencyRules, 'required'); + } + + $rules = [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::in(self::MONITOR_TYPES)], + 'url' => ['required', 'string', 'max:2048'], + 'frequency_minutes' => $frequencyRules, + 'config' => ['nullable', 'array'], + 'config.port' => $portRules, + 'config.timeout' => ['nullable', 'integer', 'min:1', 'max:120'], + 'config.count' => ['nullable', 'integer', 'min:1', 'max:10'], + 'config.method' => ['nullable', Rule::in(['HEAD', 'GET'])], + 'config.expected_status' => ['nullable', 'integer', 'min:100', 'max:599'], + ]; + + if ($type === 'http') { + $rules['url'][] = 'url'; + } + + return $request->validate($rules); + } + + private function extractTargetAndConfig(array $data): array + { + $type = $data['type']; + $target = trim($data['url']); + $config = Arr::only($data['config'] ?? [], ['host', 'port', 'timeout', 'count', 'method', 'expected_status']); + + $config['target'] = $target; + + if (isset($config['port'])) { + $config['port'] = (int) $config['port']; + } + if (isset($config['timeout'])) { + $config['timeout'] = (int) $config['timeout']; + } + if (isset($config['count'])) { + $config['count'] = (int) $config['count']; + } + if (isset($config['expected_status'])) { + $config['expected_status'] = (int) $config['expected_status']; + } + + if ($type === 'ping') { + $config['host'] = $target; + } + + if ($type === 'tcp') { + $config['host'] = $config['host'] ?? $target; + if (isset($config['host'], $config['port'])) { + $config['target'] = sprintf('%s:%s', $config['host'], $config['port']); + } + } + + if ($type === 'ssl') { + $config['host'] = $config['host'] ?? $target; + $config['port'] = $config['port'] ?? 443; + if (isset($config['host'], $config['port'])) { + $config['target'] = sprintf('%s:%s', $config['host'], $config['port']); + } + } + + $config = array_filter($config, fn ($value) => ! is_null($value)); + + return [$target, $config]; + } } diff --git a/app/Http/Controllers/MonitorPolicyController.php b/app/Http/Controllers/MonitorPolicyController.php new file mode 100644 index 0000000..cd3c2d8 --- /dev/null +++ b/app/Http/Controllers/MonitorPolicyController.php @@ -0,0 +1,92 @@ +get(); + return Inertia::render('Monitors/Policies/Index', [ + 'policies' => $policies, + ]); + } + + public function create() + { + return Inertia::render('Monitors/Policies/Create', [ + 'monitors' => Monitor::orderBy('name')->get(['id','name']), + 'channels' => NotificationChannel::where('is_active', true)->orderBy('name')->get(['id','name','type']), + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'monitor_id' => ['required','exists:monitors,id'], + 'down_threshold' => ['required','integer','min:1','max:10'], + 'notify_after_seconds' => ['nullable','integer','min:0','max:3600'], + 'repeat_interval_minutes' => ['nullable','integer','min:1','max:1440'], + 'notify_on_recovery' => ['sometimes','boolean'], + 'enabled' => ['sometimes','boolean'], + 'channel_ids' => ['array'], + 'channel_ids.*' => ['exists:notification_channels,id'], + ]); + $policy = MonitorPolicy::create([ + 'monitor_id' => $data['monitor_id'], + 'down_threshold' => $data['down_threshold'], + 'notify_after_seconds' => $data['notify_after_seconds'] ?? 0, + 'repeat_interval_minutes' => $data['repeat_interval_minutes'] ?? null, + 'notify_on_recovery' => (bool)($data['notify_on_recovery'] ?? true), + 'enabled' => (bool)($data['enabled'] ?? true), + ]); + $policy->channels()->sync($data['channel_ids'] ?? []); + return redirect()->route('policies.index')->with('success','Policy created.'); + } + + public function edit(MonitorPolicy $policy) + { + return Inertia::render('Monitors/Policies/Edit', [ + 'policy' => $policy->load('channels:id'), + 'monitors' => Monitor::orderBy('name')->get(['id','name']), + 'channels' => NotificationChannel::where('is_active', true)->orderBy('name')->get(['id','name','type']), + ]); + } + + public function update(Request $request, MonitorPolicy $policy) + { + $data = $request->validate([ + 'monitor_id' => ['required','exists:monitors,id'], + 'down_threshold' => ['required','integer','min:1','max:10'], + 'notify_after_seconds' => ['nullable','integer','min:0','max:3600'], + 'repeat_interval_minutes' => ['nullable','integer','min:1','max:1440'], + 'notify_on_recovery' => ['sometimes','boolean'], + 'enabled' => ['sometimes','boolean'], + 'channel_ids' => ['array'], + 'channel_ids.*' => ['exists:notification_channels,id'], + ]); + $policy->update([ + 'monitor_id' => $data['monitor_id'], + 'down_threshold' => $data['down_threshold'], + 'notify_after_seconds' => $data['notify_after_seconds'] ?? 0, + 'repeat_interval_minutes' => $data['repeat_interval_minutes'] ?? null, + 'notify_on_recovery' => (bool)($data['notify_on_recovery'] ?? true), + 'enabled' => (bool)($data['enabled'] ?? true), + ]); + $policy->channels()->sync($data['channel_ids'] ?? []); + return redirect()->route('policies.index')->with('success','Policy updated.'); + } + + public function destroy(MonitorPolicy $policy) + { + $policy->delete(); + return redirect()->route('policies.index')->with('success','Policy deleted.'); + } +} + diff --git a/app/Http/Controllers/MonitorWebhookController.php b/app/Http/Controllers/MonitorWebhookController.php new file mode 100644 index 0000000..331fbc6 --- /dev/null +++ b/app/Http/Controllers/MonitorWebhookController.php @@ -0,0 +1,56 @@ +first(); + + if (! $monitor || $monitor->type !== 'webhook') { + return response()->json(['message' => 'Monitor not found.'], 404); + } + + $validated = $request->validate([ + 'status' => ['required', 'in:up,down'], + 'status_code' => ['nullable', 'integer', 'min:100', 'max:599'], + 'response_time_ms' => ['nullable', 'integer', 'min:0', 'max:600000'], + 'error' => ['nullable', 'string', 'max:2048'], + 'meta' => ['nullable', 'array'], + 'payload' => ['nullable', 'array'], + 'checked_at' => ['nullable', 'date'], + ]); + + $result = new MonitorResult( + status: $validated['status'], + statusCode: $validated['status_code'] ?? null, + responseTimeMs: $validated['response_time_ms'] ?? null, + error: $validated['error'] ?? null, + meta: $validated['meta'] ?? [], + ); + + if (isset($validated['payload'])) { + $result = $result->withPayload($validated['payload']); + } + + if (! empty($validated['checked_at'])) { + $result = $result->withCheckedAt(Carbon::parse($validated['checked_at'])); + } + + $stateService->apply($monitor, $result); + + return response()->json([ + 'message' => 'Monitor state updated.', + 'monitor_id' => $monitor->id, + ], 202); + } +} + diff --git a/app/Http/Controllers/NotificationChannelController.php b/app/Http/Controllers/NotificationChannelController.php new file mode 100644 index 0000000..0c48393 --- /dev/null +++ b/app/Http/Controllers/NotificationChannelController.php @@ -0,0 +1,68 @@ +orderBy('name')->get(); + return Inertia::render('Alerts/Channels/Index', [ + 'channels' => $channels, + ]); + } + + public function create() + { + return Inertia::render('Alerts/Channels/Create', [ + 'organizations' => Organization::orderBy('name')->get(['id','name']), + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'organization_id' => ['nullable','exists:organizations,id'], + 'name' => ['required','string','max:255'], + 'type' => ['required','in:email,slack_webhook'], + 'config' => ['nullable','array'], + 'is_active' => ['sometimes','boolean'], + ]); + $data['is_active'] = (bool)($data['is_active'] ?? true); + NotificationChannel::create($data); + return redirect()->route('channels.index')->with('success','Channel created.'); + } + + public function edit(NotificationChannel $channel) + { + return Inertia::render('Alerts/Channels/Edit', [ + 'channel' => $channel, + 'organizations' => Organization::orderBy('name')->get(['id','name']), + ]); + } + + public function update(Request $request, NotificationChannel $channel) + { + $data = $request->validate([ + 'organization_id' => ['nullable','exists:organizations,id'], + 'name' => ['required','string','max:255'], + 'type' => ['required','in:email,slack_webhook'], + 'config' => ['nullable','array'], + 'is_active' => ['sometimes','boolean'], + ]); + $channel->update($data); + return redirect()->route('channels.index')->with('success','Channel updated.'); + } + + public function destroy(NotificationChannel $channel) + { + $channel->delete(); + return redirect()->route('channels.index')->with('success','Channel deleted.'); + } +} + diff --git a/app/Http/Controllers/StatusPageController.php b/app/Http/Controllers/StatusPageController.php new file mode 100644 index 0000000..013048e --- /dev/null +++ b/app/Http/Controllers/StatusPageController.php @@ -0,0 +1,57 @@ +subDay(); + $monitors = Monitor::query() + ->select(['id', 'name', 'url', 'status', 'last_response_time_ms', 'last_checked_at']) + ->orderBy('name') + ->get(); + + $uptime = MonitorCheck::query() + ->select('monitor_id', + DB::raw("SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as up_count"), + DB::raw('COUNT(*) as total_count')) + ->where('checked_at', '>=', $since) + ->groupBy('monitor_id') + ->pluck('up_count', 'monitor_id'); + + $totals = MonitorCheck::query() + ->select('monitor_id', DB::raw('COUNT(*) as total_count')) + ->where('checked_at', '>=', $since) + ->groupBy('monitor_id') + ->pluck('total_count', 'monitor_id'); + + $items = $monitors->map(function ($m) use ($uptime, $totals) { + $up = (int) ($uptime[$m->id] ?? 0); + $total = (int) ($totals[$m->id] ?? 0); + $pct = $total > 0 ? round(($up / max($total, 1)) * 100, 2) : null; + return [ + 'id' => $m->id, + 'name' => $m->name, + 'url' => $m->url, + 'status' => $m->status, + 'last_response_time_ms' => $m->last_response_time_ms, + 'last_checked_at' => optional($m->last_checked_at)->toIso8601String(), + 'uptime_24h' => $pct, + ]; + }); + + return Inertia::render('Status/Index', [ + 'monitors' => $items, + 'since' => $since->toIso8601String(), + ]); + } +} + diff --git a/app/Jobs/CheckMonitor.php b/app/Jobs/CheckMonitor.php index c6ca88a..b566ec5 100644 --- a/app/Jobs/CheckMonitor.php +++ b/app/Jobs/CheckMonitor.php @@ -3,13 +3,15 @@ namespace App\Jobs; use App\Models\Monitor; -use App\Models\MonitorCheck; +use App\Services\Monitoring\MonitorDriverManager; +use App\Services\Monitoring\MonitorResult; +use App\Services\Monitoring\MonitorStateService; 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\Str; +use Illuminate\Support\Facades\Log; class CheckMonitor implements ShouldQueue { @@ -22,121 +24,36 @@ public function handle(): void $monitor = $this->monitor->fresh(); if (! $monitor) return; - $url = $monitor->url; - $start = microtime(true); - $status = 'down'; - $statusCode = null; - $error = null; - $responseTime = null; - $sslData = [ 'ssl_valid_from' => null, 'ssl_expires_at' => null, 'ssl_issuer' => null ]; - try { - $isHttps = Str::startsWith(strtolower($url), 'https://'); - $context = null; - if ($isHttps) { - $context = stream_context_create([ - 'ssl' => [ - 'capture_peer_cert' => true, - 'verify_peer' => false, - 'verify_peer_name' => false, - ], - ]); - } - $method = 'HEAD'; - $opts = [ - 'http' => [ - 'method' => $method, - 'timeout' => 10, - 'ignore_errors' => true, - 'header' => "User-Agent: Monitor/1.0\r\n", - ], - ]; - if ($context) { - $context = stream_context_create(array_replace_recursive($opts, stream_context_get_options($context))); - } else { - $context = stream_context_create($opts); - } - - $fp = @fopen($url, 'r', false, $context); - if (! $fp) { - $opts['http']['method'] = 'GET'; - $context = stream_context_create($opts); - $fp = @fopen($url, 'r', false, $context); - } - - $responseTime = (int) round((microtime(true) - $start) * 1000); - - if ($fp) { - $meta = stream_get_meta_data($fp); - @fclose($fp); - - if (!empty($meta['wrapper_data']) && is_array($meta['wrapper_data'])) { - foreach ($meta['wrapper_data'] as $h) { - if (preg_match('#^HTTP/[^\s]+\s+(\d{3})#i', $h, $m)) { - $statusCode = (int) $m[1]; - break; - } - } - } - - $status = ($statusCode && $statusCode >= 200 && $statusCode < 400) ? 'up' : 'down'; - - if ($isHttps) { - $parts = parse_url($url); - $host = $parts['host'] ?? null; - $port = $parts['port'] ?? 443; - if ($host) { - $ctx = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false, 'verify_peer_name' => false]]); - $client = @stream_socket_client("ssl://{$host}:{$port}", $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $ctx); - if ($client) { - $params = stream_context_get_params($client); - @fclose($client); - if (isset($params['options']['ssl']['peer_certificate'])) { - $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']); - if ($cert) { - if (isset($cert['validFrom_time_t'])) $sslData['ssl_valid_from'] = date('c', $cert['validFrom_time_t']); - if (isset($cert['validTo_time_t'])) $sslData['ssl_expires_at'] = date('c', $cert['validTo_time_t']); - if (isset($cert['issuer']) && is_array($cert['issuer'])) { - $sslData['ssl_issuer'] = implode(', ', array_map(fn($k,$v)=>"$k=$v", array_keys($cert['issuer']), $cert['issuer'])); - } - } - } - } - } - } - } else { - $error = 'Connection failed'; - } + $driver = app(MonitorDriverManager::class)->driver($monitor->type ?? 'http'); } catch (\Throwable $e) { - $error = $e->getMessage(); - $responseTime = (int) round((microtime(true) - $start) * 1000); + Log::error('Monitor driver resolution failed', ['monitor_id' => $monitor->id, 'error' => $e->getMessage()]); + $driver = null; } - MonitorCheck::create([ - 'monitor_id' => $monitor->id, - 'status' => $status === 'up' ? 'up' : 'down', - 'status_code' => $statusCode, - 'response_time_ms' => $responseTime, - 'error' => $error, - 'checked_at' => now(), - ]); + $result = $driver + ? $this->runDriver($driver, $monitor) + : MonitorResult::failure('Driver not found'); - $fields = [ - 'status' => $status, - 'last_status_code' => $statusCode, - 'last_response_time_ms' => $responseTime, - 'last_checked_at' => now(), - 'failure_reason' => $error, - ]; - $fields = array_merge($fields, array_filter($sslData, fn($v) => !is_null($v))); + app(MonitorStateService::class)->apply($monitor, $result); + } - if ($status === 'down') { - $fields['consecutive_failures'] = ($monitor->consecutive_failures ?? 0) + 1; - } else { - $fields['consecutive_failures'] = 0; + private function runDriver(?object $driver, Monitor $monitor): MonitorResult + { + if (! $driver instanceof \App\Services\Monitoring\MonitorDriver) { + return MonitorResult::failure('Invalid driver instance'); } - $monitor->update($fields); + try { + return $driver->check($monitor); + } catch (\Throwable $e) { + Log::error('Monitor driver execution failed', [ + 'monitor_id' => $monitor->id, + 'type' => $monitor->type, + 'error' => $e->getMessage(), + ]); + + return MonitorResult::failure($e->getMessage()); + } } } - diff --git a/app/Models/MaintenanceWindow.php b/app/Models/MaintenanceWindow.php new file mode 100644 index 0000000..e19f725 --- /dev/null +++ b/app/Models/MaintenanceWindow.php @@ -0,0 +1,34 @@ + 'datetime', + 'end_at' => 'datetime', + ]; + } + + public function isActive(): bool + { + $now = now(); + return $this->start_at <= $now && $this->end_at >= $now; + } +} + diff --git a/app/Models/Monitor.php b/app/Models/Monitor.php index 64c3988..0eae167 100644 --- a/app/Models/Monitor.php +++ b/app/Models/Monitor.php @@ -4,15 +4,20 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; class Monitor extends Model { /** @use HasFactory<\Database\Factories\MonitorFactory> */ use HasFactory; + protected $appends = ['display_target']; + protected $fillable = [ 'name', + 'type', 'url', + 'config', 'frequency_minutes', 'status', 'last_status_code', @@ -23,6 +28,7 @@ class Monitor extends Model 'ssl_valid_from', 'ssl_expires_at', 'ssl_issuer', + 'webhook_token', ]; protected function casts(): array @@ -31,12 +37,43 @@ protected function casts(): array 'last_checked_at' => 'datetime', 'ssl_valid_from' => 'datetime', 'ssl_expires_at' => 'datetime', + 'config' => 'array', ]; } + protected static function booted(): void + { + static::creating(function (self $monitor) { + if ($monitor->type === 'webhook' && empty($monitor->webhook_token)) { + $monitor->webhook_token = (string) Str::uuid(); + } + if ($monitor->type !== 'webhook') { + $monitor->webhook_token = null; + } + }); + + static::updating(function (self $monitor) { + if ($monitor->type === 'webhook' && empty($monitor->webhook_token)) { + $monitor->webhook_token = (string) Str::uuid(); + } + if ($monitor->type !== 'webhook') { + $monitor->webhook_token = null; + } + }); + } + + public function getDisplayTargetAttribute(): ?string + { + return $this->config['target'] ?? $this->url; + } + public function checks() { return $this->hasMany(MonitorCheck::class); } -} + public function policies() + { + return $this->hasMany(MonitorPolicy::class); + } +} diff --git a/app/Models/MonitorCheck.php b/app/Models/MonitorCheck.php index 561fe2d..23678b7 100644 --- a/app/Models/MonitorCheck.php +++ b/app/Models/MonitorCheck.php @@ -16,6 +16,8 @@ class MonitorCheck extends Model 'status_code', 'response_time_ms', 'error', + 'meta', + 'payload', 'checked_at', ]; @@ -23,6 +25,8 @@ protected function casts(): array { return [ 'checked_at' => 'datetime', + 'meta' => 'array', + 'payload' => 'array', ]; } @@ -31,4 +35,3 @@ public function monitor() return $this->belongsTo(Monitor::class); } } - diff --git a/app/Models/MonitorPolicy.php b/app/Models/MonitorPolicy.php new file mode 100644 index 0000000..af65a1e --- /dev/null +++ b/app/Models/MonitorPolicy.php @@ -0,0 +1,39 @@ + 'boolean', + 'enabled' => 'boolean', + ]; + } + + public function monitor() + { + return $this->belongsTo(Monitor::class); + } + + public function channels() + { + return $this->belongsToMany(NotificationChannel::class, 'monitor_policy_channel', 'monitor_policy_id', 'notification_channel_id'); + } +} + diff --git a/app/Models/NotificationChannel.php b/app/Models/NotificationChannel.php new file mode 100644 index 0000000..7e824e7 --- /dev/null +++ b/app/Models/NotificationChannel.php @@ -0,0 +1,33 @@ + 'array', + 'is_active' => 'boolean', + ]; + } + + public function organization() + { + return $this->belongsTo(Organization::class); + } +} + diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..04ff2e4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Services\Monitoring\MonitorDriverManager; +use App\Services\Monitoring\MonitorStateService; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +13,13 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(MonitorDriverManager::class, function ($app) { + return new MonitorDriverManager($app); + }); + + $this->app->singleton(MonitorStateService::class, function ($app) { + return new MonitorStateService(); + }); } /** diff --git a/app/Services/Alerting/Notifier.php b/app/Services/Alerting/Notifier.php new file mode 100644 index 0000000..d71200b --- /dev/null +++ b/app/Services/Alerting/Notifier.php @@ -0,0 +1,104 @@ + $monitor->id, 'event' => $event]); + return; + } + + $policies = $monitor->policies()->with(['channels' => function ($q) { + $q->where('is_active', true); + }])->where('enabled', true)->get(); + if ($policies->isEmpty()) return; + + foreach ($policies as $policy) { + // Enforce basic policy conditions + if ($event === 'down' && ($monitor->consecutive_failures ?? 0) < ($policy->down_threshold ?? 1)) { + continue; + } + if ($event === 'up' && !($policy->notify_on_recovery ?? true)) { + continue; + } + + foreach ($policy->channels as $channel) { + try { + match ($channel->type) { + 'email' => self::sendEmail($channel->config['email'] ?? null, $monitor, $event, $context), + 'slack_webhook' => self::sendSlack($channel->config['webhook_url'] ?? null, $monitor, $event, $context), + default => null, + }; + } catch (\Throwable $e) { + Log::error('Notifier failed', ['error' => $e->getMessage(), 'monitor_id' => $monitor->id]); + } + } + } + } + + private static function sendEmail(?string $email, Monitor $monitor, string $event, array $ctx): void + { + if (!$email) return; + $subject = match ($event) { + 'down' => "[ALERT] {$monitor->name} is DOWN", + 'up' => "[RECOVERY] {$monitor->name} is UP", + default => "[Monitor] {$monitor->name} {$event}", + }; + $body = self::formatBody($monitor, $event, $ctx); + Mail::raw($body, function ($m) use ($email, $subject) { + $m->to($email)->subject($subject); + }); + } + + private static function sendSlack(?string $webhookUrl, Monitor $monitor, string $event, array $ctx): void + { + if (!$webhookUrl) return; + $color = $event === 'down' ? '#E11D48' : '#16A34A'; + $body = [ + 'attachments' => [[ + 'color' => $color, + 'title' => $event === 'down' ? "ALERT: {$monitor->name} is DOWN" : "RECOVERY: {$monitor->name} is UP", + 'text' => self::formatBody($monitor, $event, $ctx), + 'mrkdwn_in' => ['text'] + ]], + ]; + Http::asJson()->post($webhookUrl, $body); + } + + private static function formatBody(Monitor $m, string $event, array $ctx): string + { + $lines = [ + "Name: {$m->name}", + "URL: {$m->url}", + "Event: {$event}", + ]; + if (!empty($ctx['status_code'])) $lines[] = 'Status code: '.$ctx['status_code']; + if (!empty($ctx['response_time_ms'])) $lines[] = 'Response time: '.$ctx['response_time_ms'].' ms'; + if (!empty($ctx['error'])) $lines[] = 'Error: '.$ctx['error']; + return implode("\n", $lines); + } + + private static function inMaintenance(Monitor $monitor): bool + { + $now = now(); + $active = MaintenanceWindow::query() + ->where('start_at', '<=', $now) + ->where('end_at', '>=', $now) + ->where(function ($q) use ($monitor) { + $q->where('scope', 'global') + ->orWhere(function ($q2) use ($monitor) { $q2->where('scope', 'monitor')->where('ref_id', $monitor->id); }); + }) + ->exists(); + return $active; + } +} diff --git a/app/Services/Monitoring/Drivers/HttpMonitorDriver.php b/app/Services/Monitoring/Drivers/HttpMonitorDriver.php new file mode 100644 index 0000000..37953fa --- /dev/null +++ b/app/Services/Monitoring/Drivers/HttpMonitorDriver.php @@ -0,0 +1,130 @@ +url; + if (! $url) { + return MonitorResult::failure('Missing URL'); + } + + $ch = curl_init($url); + $method = strtoupper(Arr::get($monitor->config, 'method', 'HEAD')); + if (! in_array($method, ['HEAD', 'GET'], true)) { + $method = 'HEAD'; + } + + $headers = [ + 'User-Agent: MSP-Monitor/2.0', + ]; + + $options = [ + CURLOPT_NOBODY => $method === 'HEAD', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => Arr::get($monitor->config, 'timeout', 15), + CURLOPT_CONNECTTIMEOUT => Arr::get($monitor->config, 'connect_timeout', 10), + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => $headers, + ]; + + $options[CURLOPT_CUSTOMREQUEST] = $method; + + curl_setopt_array($ch, $options); + + $start = microtime(true); + $response = curl_exec($ch); + $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE) ?: null; + $totalTime = (int) round((curl_getinfo($ch, CURLINFO_TOTAL_TIME) ?: microtime(true) - $start) * 1000); + $error = $response === false ? curl_error($ch) : null; + + $meta = [ + 'content_type' => curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: null, + 'primary_ip' => curl_getinfo($ch, CURLINFO_PRIMARY_IP) ?: null, + 'redirect_count' => curl_getinfo($ch, CURLINFO_REDIRECT_COUNT) ?: 0, + ]; + + curl_close($ch); + + $status = 'down'; + if ($error === null && $statusCode !== null) { + $expected = Arr::get($monitor->config, 'expected_status'); + if ($expected) { + $status = ((int) $statusCode === (int) $expected) ? 'up' : 'down'; + } else { + $status = ($statusCode >= 200 && $statusCode < 400) ? 'up' : 'down'; + } + } elseif ($error === null && $response !== false) { + $status = 'up'; + } + + $result = new MonitorResult($status, $statusCode, $totalTime, $error, array_filter($meta)); + + if (Str::startsWith(strtolower($url), 'https://')) { + $ssl = $this->fetchSslMetadata($url); + if ($ssl) { + [$validFrom, $expiresAt, $issuer] = $ssl; + $result = $result->withSsl($validFrom, $expiresAt, $issuer); + } + } + + return $result; + } + + private function fetchSslMetadata(string $url): ?array + { + $parts = parse_url($url); + $host = $parts['host'] ?? null; + if (! $host) { + return null; + } + + $port = $parts['port'] ?? 443; + $context = stream_context_create([ + 'ssl' => [ + 'capture_peer_cert' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ]); + + $client = @stream_socket_client("ssl://{$host}:{$port}", $errno, $errstr, 15, STREAM_CLIENT_CONNECT, $context); + if (! $client) { + return null; + } + + $params = stream_context_get_params($client); + @fclose($client); + + if (! isset($params['options']['ssl']['peer_certificate'])) { + return null; + } + + $cert = @openssl_x509_parse($params['options']['ssl']['peer_certificate']); + if (! is_array($cert)) { + return null; + } + + $validFrom = isset($cert['validFrom_time_t']) ? Carbon::createFromTimestamp($cert['validFrom_time_t']) : null; + $validTo = isset($cert['validTo_time_t']) ? Carbon::createFromTimestamp($cert['validTo_time_t']) : null; + $issuer = null; + + if (isset($cert['issuer']) && is_array($cert['issuer'])) { + $issuer = collect($cert['issuer']) + ->map(fn ($value, $key) => "{$key}={$value}") + ->implode(', '); + } + + return [$validFrom, $validTo, $issuer]; + } +} diff --git a/app/Services/Monitoring/Drivers/PingMonitorDriver.php b/app/Services/Monitoring/Drivers/PingMonitorDriver.php new file mode 100644 index 0000000..2ae761c --- /dev/null +++ b/app/Services/Monitoring/Drivers/PingMonitorDriver.php @@ -0,0 +1,89 @@ +config['host'] ?? $monitor->url; + if (! $target) { + return MonitorResult::failure('Missing host'); + } + + if (! preg_match('/^[A-Za-z0-9_.:-]+$/', $target)) { + return MonitorResult::failure('Invalid host characters'); + } + + $count = (int) Arr::get($monitor->config, 'count', 4); + $timeout = (int) Arr::get($monitor->config, 'timeout', 5); + + $command = PHP_OS_FAMILY === 'Windows' + ? sprintf('ping -n %d -w %d %s', max(1, $count), max(1000, $timeout * 1000), escapeshellarg($target)) + : sprintf('ping -c %d -W %d %s', max(1, $count), max(1, $timeout), escapeshellarg($target)); + + $start = microtime(true); + [$exitCode, $output] = $this->runShellCommand($command); + $elapsed = (int) round((microtime(true) - $start) * 1000); + + if ($exitCode !== 0) { + return MonitorResult::failure('Ping failed', ['output' => $output], null, $elapsed); + } + + $meta = $this->parsePingOutput($output); + + return MonitorResult::success($meta, null, $elapsed); + } + + private function runShellCommand(string $command): array + { + $descriptor = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptor, $pipes); + if (! is_resource($process)) { + return [1, 'Failed to start ping command']; + } + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + foreach ($pipes as $pipe) { + fclose($pipe); + } + + $exitCode = proc_close($process); + + if ($exitCode === -1 && ! empty($stderr)) { + $exitCode = 1; + } + + $output = trim($stdout ?: $stderr); + return [$exitCode, $output]; + } + + private function parsePingOutput(string $output): array + { + $meta = ['raw_output' => $output]; + + if (preg_match('/=\s*([\d.]+)/', $output, $avg)) { + $meta['rtt_avg_ms'] = (float) $avg[1]; + } + + if (preg_match('/(\d+)%\s*packet loss/', $output, $loss)) { + $meta['packet_loss_percent'] = (int) $loss[1]; + } + + if (preg_match('/time[=<]\s*(\d+\.?\d*)ms/', $output, $time)) { + $meta['last_reply_ms'] = (float) $time[1]; + } + + return $meta; + } +} diff --git a/app/Services/Monitoring/Drivers/SslCertificateMonitorDriver.php b/app/Services/Monitoring/Drivers/SslCertificateMonitorDriver.php new file mode 100644 index 0000000..3533a09 --- /dev/null +++ b/app/Services/Monitoring/Drivers/SslCertificateMonitorDriver.php @@ -0,0 +1,77 @@ +config['host'] ?? $monitor->url; + $port = (int) Arr::get($monitor->config, 'port', 443); + + if (! $host) { + return MonitorResult::failure('Missing host'); + } + + $context = stream_context_create([ + 'ssl' => [ + 'capture_peer_cert' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ]); + + $start = microtime(true); + $client = @stream_socket_client("ssl://{$host}:{$port}", $errno, $errstr, 15, STREAM_CLIENT_CONNECT, $context); + $duration = (int) round((microtime(true) - $start) * 1000); + + if (! $client) { + $meta = ['host' => $host, 'port' => $port, 'errno' => $errno]; + return MonitorResult::failure($errstr ?: 'Unable to negotiate SSL', $meta, null, $duration); + } + + $params = stream_context_get_params($client); + fclose($client); + + if (! isset($params['options']['ssl']['peer_certificate'])) { + return MonitorResult::failure('Certificate not available', ['host' => $host, 'port' => $port], null, $duration); + } + + $cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']); + if (! is_array($cert)) { + return MonitorResult::failure('Unable to parse certificate', ['host' => $host, 'port' => $port], null, $duration); + } + + $validFrom = isset($cert['validFrom_time_t']) ? Carbon::createFromTimestamp($cert['validFrom_time_t']) : null; + $validTo = isset($cert['validTo_time_t']) ? Carbon::createFromTimestamp($cert['validTo_time_t']) : null; + $issuer = null; + + if (isset($cert['issuer']) && is_array($cert['issuer'])) { + $issuer = collect($cert['issuer']) + ->map(fn ($value, $key) => "{$key}={$value}") + ->implode(', '); + } + + $meta = [ + 'subject' => $cert['name'] ?? null, + 'valid_from' => $validFrom?->toIso8601String(), + 'valid_to' => $validTo?->toIso8601String(), + 'serial_number' => $cert['serialNumberHex'] ?? null, + ]; + + if ($validTo) { + $meta['days_until_expiry'] = $validTo->isPast() + ? -$validTo->diffInDays(now()) + : now()->diffInDays($validTo); + } + + $result = MonitorResult::success($meta, null, $duration); + return $result->withSsl($validFrom, $validTo, $issuer); + } +} diff --git a/app/Services/Monitoring/Drivers/TcpMonitorDriver.php b/app/Services/Monitoring/Drivers/TcpMonitorDriver.php new file mode 100644 index 0000000..58b0958 --- /dev/null +++ b/app/Services/Monitoring/Drivers/TcpMonitorDriver.php @@ -0,0 +1,40 @@ +config['host'] ?? $monitor->url; + $port = (int) Arr::get($monitor->config, 'port', 0); + $timeout = (int) Arr::get($monitor->config, 'timeout', 10); + + if (! $host || $port < 1 || $port > 65535) { + return MonitorResult::failure('Invalid host or port'); + } + + $start = microtime(true); + $errno = 0; + $errstr = null; + $connection = @fsockopen($host, $port, $errno, $errstr, max(1, $timeout)); + $duration = (int) round((microtime(true) - $start) * 1000); + + if (! $connection) { + $message = $errstr ?: 'Connection failed'; + $meta = ['errno' => $errno, 'host' => $host, 'port' => $port]; + return MonitorResult::failure($message, $meta, null, $duration); + } + + fclose($connection); + + $meta = ['host' => $host, 'port' => $port]; + return MonitorResult::success($meta, null, $duration); + } +} + diff --git a/app/Services/Monitoring/Drivers/WebhookMonitorDriver.php b/app/Services/Monitoring/Drivers/WebhookMonitorDriver.php new file mode 100644 index 0000000..8954593 --- /dev/null +++ b/app/Services/Monitoring/Drivers/WebhookMonitorDriver.php @@ -0,0 +1,16 @@ +> */ + protected array $map = [ + 'http' => \App\Services\Monitoring\Drivers\HttpMonitorDriver::class, + 'ping' => \App\Services\Monitoring\Drivers\PingMonitorDriver::class, + 'tcp' => \App\Services\Monitoring\Drivers\TcpMonitorDriver::class, + 'ssl' => \App\Services\Monitoring\Drivers\SslCertificateMonitorDriver::class, + 'webhook' => \App\Services\Monitoring\Drivers\WebhookMonitorDriver::class, + ]; + + /** @var array */ + protected array $drivers = []; + + public function __construct(protected Container $container) {} + + public function register(string $type, string $driverClass): void + { + $this->map[$type] = $driverClass; + unset($this->drivers[$type]); + } + + public function driver(?string $type): ?MonitorDriver + { + if (! $type) { + return null; + } + + if (! isset($this->drivers[$type])) { + $class = $this->map[$type] ?? null; + if (! $class) { + return null; + } + $this->drivers[$type] = $this->container->make($class); + } + + return $this->drivers[$type]; + } +} + diff --git a/app/Services/Monitoring/MonitorResult.php b/app/Services/Monitoring/MonitorResult.php new file mode 100644 index 0000000..71e78a0 --- /dev/null +++ b/app/Services/Monitoring/MonitorResult.php @@ -0,0 +1,80 @@ +status = in_array($status, ['up', 'down', 'unknown'], true) ? $status : 'unknown'; + } + + public static function success(array $meta = [], ?int $statusCode = null, ?int $responseTimeMs = null): self + { + return new self('up', $statusCode, $responseTimeMs, null, $meta); + } + + public static function failure(string $message, array $meta = [], ?int $statusCode = null, ?int $responseTimeMs = null): self + { + return new self('down', $statusCode, $responseTimeMs, $message, $meta); + } + + public static function skip(string $reason = 'skipped'): self + { + return new self('unknown', null, null, $reason, recordHistory: false); + } + + public function withSsl(?Carbon $validFrom, ?Carbon $expiresAt, ?string $issuer): self + { + $clone = clone $this; + $clone->sslValidFrom = $validFrom; + $clone->sslExpiresAt = $expiresAt; + $clone->sslIssuer = $issuer; + return $clone; + } + + public function withPayload(?array $payload): self + { + $clone = clone $this; + $clone->payload = $payload; + return $clone; + } + + public function withCheckedAt(?Carbon $checkedAt): self + { + $clone = clone $this; + $clone->checkedAt = $checkedAt; + return $clone; + } + + public function toArray(): array + { + return [ + 'status' => $this->status, + 'status_code' => $this->statusCode, + 'response_time_ms' => $this->responseTimeMs, + 'error' => $this->error, + 'meta' => $this->meta, + 'payload' => $this->payload, + 'ssl_valid_from' => $this->sslValidFrom?->toIso8601String(), + 'ssl_expires_at' => $this->sslExpiresAt?->toIso8601String(), + 'ssl_issuer' => $this->sslIssuer, + 'checked_at' => $this->checkedAt?->toIso8601String(), + 'record_history' => $this->recordHistory, + ]; + } +} diff --git a/app/Services/Monitoring/MonitorStateService.php b/app/Services/Monitoring/MonitorStateService.php new file mode 100644 index 0000000..f0b713f --- /dev/null +++ b/app/Services/Monitoring/MonitorStateService.php @@ -0,0 +1,79 @@ +status ?? 'unknown'; + + $checkedAt = $result->checkedAt ?? now(); + + if ($result->recordHistory) { + MonitorCheck::create([ + 'monitor_id' => $monitor->id, + 'status' => $result->status === 'up' ? 'up' : 'down', + 'status_code' => $result->statusCode, + 'response_time_ms' => $result->responseTimeMs, + 'error' => $result->error, + 'meta' => $result->meta ?: null, + 'payload' => $result->payload, + 'checked_at' => $checkedAt, + ]); + } + + if (! $result->recordHistory) { + return; + } + + $fields = [ + 'status' => $result->status, + 'last_status_code' => $result->statusCode, + 'last_response_time_ms' => $result->responseTimeMs, + 'last_checked_at' => $checkedAt, + 'failure_reason' => $result->error, + ]; + + if ($result->sslValidFrom) { + $fields['ssl_valid_from'] = $result->sslValidFrom; + } + if ($result->sslExpiresAt) { + $fields['ssl_expires_at'] = $result->sslExpiresAt; + } + if ($result->sslIssuer) { + $fields['ssl_issuer'] = $result->sslIssuer; + } + + if ($result->status === 'down') { + $fields['consecutive_failures'] = ($monitor->consecutive_failures ?? 0) + 1; + } else { + $fields['consecutive_failures'] = 0; + } + + $monitor->update($fields); + + if (! $notify || $prevStatus === $result->status) { + return; + } + + try { + Notifier::monitorEvent($monitor->fresh(), $result->status === 'down' ? 'down' : 'up', [ + 'status_code' => $result->statusCode, + 'response_time_ms' => $result->responseTimeMs, + 'error' => $result->error, + 'meta' => $result->meta, + ]); + } catch (\Throwable $e) { + Log::error('Monitor notification failed', [ + 'monitor_id' => $monitor->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 134581a..acf0933 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,6 +10,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/database/factories/MonitorFactory.php b/database/factories/MonitorFactory.php new file mode 100644 index 0000000..1a1bfb6 --- /dev/null +++ b/database/factories/MonitorFactory.php @@ -0,0 +1,55 @@ + + */ +class MonitorFactory extends Factory +{ + protected $model = Monitor::class; + + public function definition(): array + { + $type = $this->faker->randomElement(['http', 'ping', 'tcp']); + $url = match ($type) { + 'ping' => $this->faker->ipv4(), + 'tcp' => $this->faker->domainName(), + default => $this->faker->url(), + }; + + $config = match ($type) { + 'ping' => ['target' => $url, 'host' => $url, 'timeout' => 5, 'count' => 4], + 'tcp' => ['target' => $url.':443', 'host' => $url, 'port' => 443, 'timeout' => 5], + default => ['target' => $url, 'method' => 'HEAD', 'timeout' => 15], + }; + + return [ + 'name' => $this->faker->sentence(3), + 'type' => $type, + 'url' => $url, + 'config' => $config, + 'frequency_minutes' => $this->faker->randomElement([1, 5, 10, 15]), + 'status' => 'unknown', + 'webhook_token' => $type === 'webhook' ? Str::uuid()->toString() : null, + ]; + } + + public function webhook(): self + { + return $this->state(function () { + $token = (string) Str::uuid(); + return [ + 'type' => 'webhook', + 'url' => 'webhook://'.$token, + 'config' => ['target' => $token], + 'webhook_token' => $token, + 'frequency_minutes' => 5, + ]; + }); + } +} diff --git a/database/migrations/2025_09_14_130000_create_notification_channels_table.php b/database/migrations/2025_09_14_130000_create_notification_channels_table.php new file mode 100644 index 0000000..a5ed069 --- /dev/null +++ b/database/migrations/2025_09_14_130000_create_notification_channels_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('organization_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->enum('type', ['email', 'slack_webhook']); + $table->json('config')->nullable(); // e.g., {"email":"ops@example.com"} or {"webhook_url":"..."} + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_channels'); + } +}; + diff --git a/database/migrations/2025_09_14_130100_create_monitor_policies_table.php b/database/migrations/2025_09_14_130100_create_monitor_policies_table.php new file mode 100644 index 0000000..06a3d8f --- /dev/null +++ b/database/migrations/2025_09_14_130100_create_monitor_policies_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('monitor_id')->constrained('monitors')->onDelete('cascade'); + $table->unsignedSmallInteger('down_threshold')->default(1); // consecutive downs before alert + $table->unsignedSmallInteger('notify_after_seconds')->default(0); // delay before first alert + $table->unsignedSmallInteger('repeat_interval_minutes')->nullable(); // null = no repeats + $table->boolean('notify_on_recovery')->default(true); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + + Schema::create('monitor_policy_channel', function (Blueprint $table) { + $table->id(); + $table->foreignId('monitor_policy_id')->constrained('monitor_policies')->onDelete('cascade'); + $table->foreignId('notification_channel_id')->constrained('notification_channels')->onDelete('cascade'); + $table->timestamps(); + $table->unique(['monitor_policy_id', 'notification_channel_id'], 'mpc_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('monitor_policy_channel'); + Schema::dropIfExists('monitor_policies'); + } +}; + diff --git a/database/migrations/2025_09_14_130200_create_maintenance_windows_table.php b/database/migrations/2025_09_14_130200_create_maintenance_windows_table.php new file mode 100644 index 0000000..682d97d --- /dev/null +++ b/database/migrations/2025_09_14_130200_create_maintenance_windows_table.php @@ -0,0 +1,30 @@ +id(); + $table->enum('scope', ['global', 'organization', 'device', 'monitor']); + $table->unsignedBigInteger('ref_id')->nullable(); // organization_id, device_id, or monitor_id depending on scope + $table->timestamp('start_at'); + $table->timestamp('end_at'); + $table->string('reason')->nullable(); + $table->timestamps(); + + $table->index(['scope', 'ref_id']); + $table->index(['start_at', 'end_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('maintenance_windows'); + } +}; + diff --git a/database/migrations/2025_10_01_190348_extend_monitors_and_checks.php b/database/migrations/2025_10_01_190348_extend_monitors_and_checks.php new file mode 100644 index 0000000..762ea13 --- /dev/null +++ b/database/migrations/2025_10_01_190348_extend_monitors_and_checks.php @@ -0,0 +1,33 @@ +string('type')->default('http')->after('name'); + $table->json('config')->nullable()->after('url'); + $table->string('webhook_token')->nullable()->after('config')->unique(); + }); + + Schema::table('monitor_checks', function (Blueprint $table) { + $table->json('meta')->nullable()->after('error'); + $table->json('payload')->nullable()->after('meta'); + }); + } + + public function down(): void + { + Schema::table('monitor_checks', function (Blueprint $table) { + $table->dropColumn(['meta', 'payload']); + }); + + Schema::table('monitors', function (Blueprint $table) { + $table->dropColumn(['type', 'config', 'webhook_token']); + }); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index ba40276..98388fb 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -6,7 +6,7 @@ import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, Sid import { dashboard } from '@/routes'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/vue3'; -import { BookOpen, Folder, LayoutGrid, Building2, Server, Activity, Cloud } from 'lucide-vue-next'; +import { BookOpen, Folder, LayoutGrid, Building2, Server, Activity, Cloud, Bell, Wrench } from 'lucide-vue-next'; import AppLogo from './AppLogo.vue'; const mainNavItems: NavItem[] = [ @@ -40,6 +40,21 @@ const mainNavItems: NavItem[] = [ href: '/monitors', icon: Activity, }, + { + title: 'Alert Channels', + href: '/channels', + icon: Bell, + }, + { + title: 'Policies', + href: '/policies', + icon: Bell, + }, + { + title: 'Maintenance', + href: '/maintenance', + icon: Wrench, + }, { title: 'UniFi Sites', href: '/integrations/unifi', diff --git a/resources/js/pages/Alerts/Channels/Create.vue b/resources/js/pages/Alerts/Channels/Create.vue new file mode 100644 index 0000000..064a156 --- /dev/null +++ b/resources/js/pages/Alerts/Channels/Create.vue @@ -0,0 +1,70 @@ + + + + diff --git a/resources/js/pages/Alerts/Channels/Edit.vue b/resources/js/pages/Alerts/Channels/Edit.vue new file mode 100644 index 0000000..35a0e8c --- /dev/null +++ b/resources/js/pages/Alerts/Channels/Edit.vue @@ -0,0 +1,71 @@ + + + + diff --git a/resources/js/pages/Alerts/Channels/Index.vue b/resources/js/pages/Alerts/Channels/Index.vue new file mode 100644 index 0000000..8c33c6b --- /dev/null +++ b/resources/js/pages/Alerts/Channels/Index.vue @@ -0,0 +1,56 @@ + + + + diff --git a/resources/js/pages/Docs/WebhooksMonitors.vue b/resources/js/pages/Docs/WebhooksMonitors.vue new file mode 100644 index 0000000..66ad72f --- /dev/null +++ b/resources/js/pages/Docs/WebhooksMonitors.vue @@ -0,0 +1,151 @@ + + + + diff --git a/resources/js/pages/Maintenance/Create.vue b/resources/js/pages/Maintenance/Create.vue new file mode 100644 index 0000000..ec3fdbb --- /dev/null +++ b/resources/js/pages/Maintenance/Create.vue @@ -0,0 +1,65 @@ + + + + diff --git a/resources/js/pages/Maintenance/Edit.vue b/resources/js/pages/Maintenance/Edit.vue new file mode 100644 index 0000000..9aae4d0 --- /dev/null +++ b/resources/js/pages/Maintenance/Edit.vue @@ -0,0 +1,66 @@ + + + + diff --git a/resources/js/pages/Maintenance/Index.vue b/resources/js/pages/Maintenance/Index.vue new file mode 100644 index 0000000..89bf9ed --- /dev/null +++ b/resources/js/pages/Maintenance/Index.vue @@ -0,0 +1,55 @@ + + + + diff --git a/resources/js/pages/Monitors/Create.vue b/resources/js/pages/Monitors/Create.vue index 283e8de..b6e4304 100644 --- a/resources/js/pages/Monitors/Create.vue +++ b/resources/js/pages/Monitors/Create.vue @@ -1,4 +1,5 @@