diff --git a/composer.json b/composer.json index f409f892..b1ea29b8 100644 --- a/composer.json +++ b/composer.json @@ -92,20 +92,31 @@ "post-autoload-dump": "@prepare", "build": [ "@prepare", - "@clear", "@php vendor/bin/testbench workbench:build" ], "canvas": "@php vendor/bin/canvas", "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", "dev": [ - "@build", "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php vendor/bin/testbench serve\" \"php vendor/bin/testbench queue:listen --tries=1\" \"php vendor/bin/testbench pail\" \"npm run dev\" --names=server,queue,logs,vite" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#86efac\" \"php vendor/bin/testbench serve\" \"php vendor/bin/testbench queue:listen --tries=1\" \"npm run dev\" \"node scheduler.cjs\" --names=server,queue,vite,scheduler" ], "prepare": "@php vendor/bin/testbench package:discover --ansi", "start": [ "@dev" ], + "fresh": [ + "@php vendor/bin/testbench migrate:fresh --seed --seeder=Cachet\\Database\\Seeders\\DatabaseSeeder", + "@serve" + ], + "serve": [ + "Composer\\Config::disableProcessTimeout", + "php vendor/bin/testbench serve --host=0.0.0.0 --port=8000" + ], + "setup": [ + "@prepare", + "@php vendor/bin/testbench workbench:build", + "npm run build" + ], "test:lint": [ "pint --test" ], diff --git a/config/cachet.php b/config/cachet.php index d3b5c9cc..cc63b990 100644 --- a/config/cachet.php +++ b/config/cachet.php @@ -179,4 +179,17 @@ 'cache' => env('CACHET_FEED_CACHE', 3600), ], + /* + Uptime Kuma Integration .env variables + */ + 'uptime_kuma' => [ + 'enabled' => env('CACHET_UPTIME_KUMA_ENABLED', true), + 'url' => env('CACHET_UPTIME_KUMA_URL', 'http://localhost:3001'), + 'status_page_slug' => env('CACHET_UPTIME_KUMA_STATUS_PAGE_SLUG', 'united-codes'), + 'webhook_secret' => env('CACHET_UPTIME_KUMA_WEBHOOK_SECRET', null), + 'send_notifications' => env('CACHET_UPTIME_KUMA_NOTIFICATIONS', true), + 'auto_resolve' => env('CACHET_UPTIME_KUMA_AUTO_RESOLVE', true), + 'sync_interval' => env('CACHET_UPTIME_KUMA_SYNC_INTERVAL', 5), + ], + ]; diff --git a/database/migrations/2026_02_06_000000_add_meta_to_component_groups_table.php b/database/migrations/2026_02_06_000000_add_meta_to_component_groups_table.php new file mode 100644 index 00000000..7a439177 --- /dev/null +++ b/database/migrations/2026_02_06_000000_add_meta_to_component_groups_table.php @@ -0,0 +1,28 @@ +json('meta')->nullable()->after('visible'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('component_groups', function (Blueprint $table) { + $table->dropColumn('meta'); + }); + } +}; diff --git a/database/migrations/2026_02_09_000000_add_integration_settings.php b/database/migrations/2026_02_09_000000_add_integration_settings.php new file mode 100644 index 00000000..deb79ad8 --- /dev/null +++ b/database/migrations/2026_02_09_000000_add_integration_settings.php @@ -0,0 +1,30 @@ +migrator->add('integrations.uptime_kuma_url', null); + $this->migrator->add('integrations.uptime_kuma_status_page_slug', null); + $this->migrator->add('integrations.uptime_kuma_enabled', true); + $this->migrator->add('integrations.uptime_kuma_auto_incidents', true); + $this->migrator->add('integrations.uptime_kuma_auto_resolve', true); + $this->migrator->add('integrations.uptime_kuma_send_notifications', true); + $this->migrator->add('integrations.uptime_kuma_sync_interval', 5); + $this->migrator->add('integrations.uptime_kuma_last_sync', null); + } + + public function down(): void + { + $this->migrator->delete('integrations.uptime_kuma_url'); + $this->migrator->delete('integrations.uptime_kuma_status_page_slug'); + $this->migrator->delete('integrations.uptime_kuma_enabled'); + $this->migrator->delete('integrations.uptime_kuma_auto_incidents'); + $this->migrator->delete('integrations.uptime_kuma_auto_resolve'); + $this->migrator->delete('integrations.uptime_kuma_send_notifications'); + $this->migrator->delete('integrations.uptime_kuma_sync_interval'); + $this->migrator->delete('integrations.uptime_kuma_last_sync'); + } +}; diff --git a/database/migrations/2026_02_10_000000_add_webhook_secret_setting.php b/database/migrations/2026_02_10_000000_add_webhook_secret_setting.php new file mode 100644 index 00000000..41d528e9 --- /dev/null +++ b/database/migrations/2026_02_10_000000_add_webhook_secret_setting.php @@ -0,0 +1,16 @@ +migrator->add('integrations.uptime_kuma_webhook_secret', null); + } + + public function down(): void + { + $this->migrator->delete('integrations.uptime_kuma_webhook_secret'); + } +}; diff --git a/database/migrations/2026_02_11_000000_add_branding_settings.php b/database/migrations/2026_02_11_000000_add_branding_settings.php new file mode 100644 index 00000000..e460f18c --- /dev/null +++ b/database/migrations/2026_02_11_000000_add_branding_settings.php @@ -0,0 +1,49 @@ +migrator->add('branding.header_bg_color', null); + $this->migrator->add('branding.header_text_color', null); + $this->migrator->add('branding.header_logo', null); + $this->migrator->add('branding.header_logo_height', 32); + $this->migrator->add('branding.header_links', null); + $this->migrator->add('branding.show_subscribe_button', true); + $this->migrator->add('branding.show_dashboard_link', true); + + // Footer settings + $this->migrator->add('branding.footer_bg_color', null); + $this->migrator->add('branding.footer_text_color', null); + $this->migrator->add('branding.footer_copyright', null); + $this->migrator->add('branding.show_cachet_branding', true); + $this->migrator->add('branding.footer_links', null); + + // General branding + $this->migrator->add('branding.page_bg_color', null); + $this->migrator->add('branding.favicon_url', null); + $this->migrator->add('branding.custom_css', null); + } + + public function down(): void + { + $this->migrator->delete('branding.header_bg_color'); + $this->migrator->delete('branding.header_text_color'); + $this->migrator->delete('branding.header_logo'); + $this->migrator->delete('branding.header_logo_height'); + $this->migrator->delete('branding.header_links'); + $this->migrator->delete('branding.show_subscribe_button'); + $this->migrator->delete('branding.show_dashboard_link'); + $this->migrator->delete('branding.footer_bg_color'); + $this->migrator->delete('branding.footer_text_color'); + $this->migrator->delete('branding.footer_copyright'); + $this->migrator->delete('branding.show_cachet_branding'); + $this->migrator->delete('branding.footer_links'); + $this->migrator->delete('branding.page_bg_color'); + $this->migrator->delete('branding.favicon_url'); + $this->migrator->delete('branding.custom_css'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 69270efd..f99dcc09 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -32,16 +32,16 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - DB::table('users')->truncate(); - DB::table('incidents')->truncate(); - DB::table('components')->truncate(); - DB::table('component_groups')->truncate(); - DB::table('schedules')->truncate(); - DB::table('metrics')->truncate(); - DB::table('metric_points')->truncate(); - DB::table('updates')->truncate(); - DB::table('webhook_attempts')->truncate(); - DB::table('webhook_subscriptions')->truncate(); + // DB::table('users')->truncate(); + // DB::table('incidents')->truncate(); + // DB::table('components')->truncate(); + // DB::table('component_groups')->truncate(); + // DB::table('schedules')->truncate(); + // DB::table('metrics')->truncate(); + // DB::table('metric_points')->truncate(); + // DB::table('updates')->truncate(); + // DB::table('webhook_attempts')->truncate(); + // DB::table('webhook_subscriptions')->truncate(); /** @var User $userModel */ $userModel = config('cachet.user_model'); diff --git a/database/seeders/UptimeKumaTemplatesSeeder.php b/database/seeders/UptimeKumaTemplatesSeeder.php new file mode 100644 index 00000000..ddfc6184 --- /dev/null +++ b/database/seeders/UptimeKumaTemplatesSeeder.php @@ -0,0 +1,196 @@ + 'service-outage'], + [ + 'name' => 'Service Outage (Automated)', + 'engine' => IncidentTemplateEngineEnum::twig, + 'template' => $this->getOutageTemplate(), + ] + ); + + // Service Recovery Template + IncidentTemplate::updateOrCreate( + ['slug' => 'service-recovery'], + [ + 'name' => 'Service Recovery (Automated)', + 'engine' => IncidentTemplateEngineEnum::twig, + 'template' => $this->getRecoveryTemplate(), + ] + ); + + // Manual Maintenance Template + IncidentTemplate::updateOrCreate( + ['slug' => 'scheduled-maintenance'], + [ + 'name' => 'Scheduled Maintenance', + 'engine' => IncidentTemplateEngineEnum::twig, + 'template' => $this->getMaintenanceTemplate(), + ] + ); + + // Manual Investigation Template + IncidentTemplate::updateOrCreate( + ['slug' => 'investigating-issue'], + [ + 'name' => 'Investigating Issue', + 'engine' => IncidentTemplateEngineEnum::twig, + 'template' => $this->getInvestigatingTemplate(), + ] + ); + } + + /** + * Get the outage template content. + */ + protected function getOutageTemplate(): string + { + return <<<'TEMPLATE' +## Service Disruption Detected + +**Affected Service:** {{ component_name }} + +**Status:** Investigating + +**Detected:** {{ occurred_at }} + +--- + +### Details + +Our monitoring systems have detected an issue with **{{ service_name }}**. + +{% if url %} +- **Endpoint:** `{{ url }}` +{% endif %} +- **Error:** {{ error_message }} +{% if ping %} +- **Last Response Time:** {{ ping }}ms +{% endif %} + +--- + +### What's Next? + +Our team has been alerted and is actively investigating the issue. We will provide updates as more information becomes available. + +_This incident was automatically detected by our monitoring system._ +TEMPLATE; + } + + /** + * Get the recovery template content. + */ + protected function getRecoveryTemplate(): string + { + return <<<'TEMPLATE' +## Service Restored + +**Status:** Resolved + +**Resolved:** {{ resolved_at }} + +**Total Downtime:** {{ duration }} + +--- + +The service has been restored and is now operating normally. We apologize for any inconvenience this may have caused. + +If you continue to experience issues, please contact our support team. + +_This update was automatically generated by our monitoring system._ +TEMPLATE; + } + + /** + * Get the scheduled maintenance template content. + */ + protected function getMaintenanceTemplate(): string + { + return <<<'TEMPLATE' +## Scheduled Maintenance + +**Service:** {{ service_name | default('Multiple Services') }} + +**Scheduled Time:** {{ start_time | default('TBD') }} - {{ end_time | default('TBD') }} + +--- + +### What to Expect + +We will be performing scheduled maintenance on our systems. During this time, you may experience: + +- Brief interruptions in service +- Slower response times +- Temporary unavailability of certain features + +--- + +### Impact + +{{ impact_description | default('Minimal impact expected. Most services will remain available.') }} + +--- + +We appreciate your patience as we work to improve our services. + +_This is a planned maintenance window._ +TEMPLATE; + } + + /** + * Get the investigating template content. + */ + protected function getInvestigatingTemplate(): string + { + return <<<'TEMPLATE' +## Investigating Issue + +**Service:** {{ service_name | default('Unknown Service') }} + +**Status:** Investigating + +**Reported:** {{ reported_at | default('Just now') }} + +--- + +### Summary + +We are currently investigating reports of {{ issue_type | default('service degradation') }}. + +### Current Status + +{{ status_description | default('Our team is actively looking into this issue.') }} + +--- + +### What We Know So Far + +{{ details | default('Investigation is underway. More details will be provided as they become available.') }} + +--- + +We will update this incident as more information becomes available. +TEMPLATE; + } +} diff --git a/resources/views/components/branding/footer-content.blade.php b/resources/views/components/branding/footer-content.blade.php new file mode 100644 index 00000000..41bddf87 --- /dev/null +++ b/resources/views/components/branding/footer-content.blade.php @@ -0,0 +1,23 @@ +{{-- Custom branding: footer content injected via render hooks --}} +@if($brandingFooterCopyright || count($brandingFooterLinks) > 0) + +@endif diff --git a/resources/views/components/branding/header-links.blade.php b/resources/views/components/branding/header-links.blade.php new file mode 100644 index 00000000..0a553306 --- /dev/null +++ b/resources/views/components/branding/header-links.blade.php @@ -0,0 +1,13 @@ +{{-- Custom branding: extra header navigation links --}} +@if(count($brandingHeaderLinks) > 0) + +@endif diff --git a/resources/views/components/branding/login-footer.blade.php b/resources/views/components/branding/login-footer.blade.php new file mode 100644 index 00000000..d27e1f19 --- /dev/null +++ b/resources/views/components/branding/login-footer.blade.php @@ -0,0 +1,23 @@ +{{-- Custom branding: login page footer --}} +@if($brandingFooterCopyright || count($brandingFooterLinks) > 0) + +@endif diff --git a/resources/views/components/branding/login-styles.blade.php b/resources/views/components/branding/login-styles.blade.php new file mode 100644 index 00000000..83a55807 --- /dev/null +++ b/resources/views/components/branding/login-styles.blade.php @@ -0,0 +1,29 @@ +{{-- Custom branding: login/auth page styles --}} + diff --git a/resources/views/components/branding/styles.blade.php b/resources/views/components/branding/styles.blade.php new file mode 100644 index 00000000..93b18ed3 --- /dev/null +++ b/resources/views/components/branding/styles.blade.php @@ -0,0 +1,85 @@ +{{-- Custom branding: dynamic CSS variables and overrides --}} + diff --git a/resources/views/components/component.blade.php b/resources/views/components/component.blade.php index 231a49ba..ccb86606 100644 --- a/resources/views/components/component.blade.php +++ b/resources/views/components/component.blade.php @@ -27,10 +27,25 @@ + {{-- Heartbeat bar visualization --}} + @php + $meta = $component->meta ?? []; + $heartbeats = $meta['heartbeats'] ?? []; + $uptime = $meta['uptime_kuma_uptime_24h'] ?? null; + $sslDays = $meta['ssl_expiry_days'] ?? null; + @endphp + @if(!empty($heartbeats) || $uptime !== null || $sslDays !== null) + + @endif +
- @if($component->description) + @if($component->link) {{ __('cachet::component.view_details') }} @endif @@ -38,4 +53,4 @@
-{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_BODY_AFTER) }} +{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_COMPONENTS_AFTER) }} diff --git a/resources/views/components/header.blade.php b/resources/views/components/header.blade.php index a133430c..70789e06 100644 --- a/resources/views/components/header.blade.php +++ b/resources/views/components/header.blade.php @@ -1,18 +1,20 @@ {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_NAVIGATION_BEFORE) }}
- + @if($appBanner) {{ $siteName }} @else -
- @if ($dashboardLoginLink)
+ {{-- Subscribe Button --}} + + + @if ($dashboardLoginLink) {{ __('filament-panels::pages/dashboard.title') }} @@ -25,7 +27,7 @@ @endauth + @endif
- @endif
{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_NAVIGATION_AFTER) }} diff --git a/resources/views/components/heartbeat-bar.blade.php b/resources/views/components/heartbeat-bar.blade.php new file mode 100644 index 00000000..05f8f531 --- /dev/null +++ b/resources/views/components/heartbeat-bar.blade.php @@ -0,0 +1,66 @@ +@props(['heartbeats' => [], 'uptime' => null, 'sslDays' => null]) + +@php + $heartbeats = is_array($heartbeats) ? $heartbeats : []; + $displayHeartbeats = array_pad($heartbeats, -30, null); + $displayHeartbeats = array_slice($displayHeartbeats, -30); +@endphp + +
+ {{-- Heartbeat bar visualization --}} +
+ @foreach($displayHeartbeats as $hb) + @php + $status = is_array($hb) ? ($hb['status'] ?? null) : null; + $ping = is_array($hb) ? ($hb['ping'] ?? null) : null; + $time = is_array($hb) ? ($hb['time'] ?? null) : null; + + // Color based on status + $colorClass = match($status) { + 1 => 'bg-green-500', + 0 => 'bg-red-500', + 2 => 'bg-yellow-500', + 3 => 'bg-blue-500', + default => 'bg-zinc-300 dark:bg-zinc-600', + }; + + $tooltip = match($status) { + 1 => 'Online' . ($ping ? " ({$ping}ms)" : ''), + 0 => 'Offline', + 2 => 'Pending', + 3 => 'Maintenance', + default => 'No data', + }; + @endphp +
+ @endforeach +
+ + {{-- Status info row --}} +
+
+ @if($uptime !== null) + @php + $uptimePercent = round($uptime * 100, 2); + $uptimeColor = $uptimePercent >= 99 ? 'text-green-600 dark:text-green-400' : + ($uptimePercent >= 95 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'); + @endphp + {{ $uptimePercent }}% uptime (24h) + @endif + @if($sslDays !== null && $sslDays > 0) + @php + $sslColor = $sslDays > 30 ? 'text-green-600 dark:text-green-400' : + ($sslDays > 7 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'); + @endphp + + + SSL: {{ $sslDays }} days + + @endif +
+ Last 30 checks +
+
diff --git a/resources/views/components/subscribe-button.blade.php b/resources/views/components/subscribe-button.blade.php new file mode 100644 index 00000000..68d00124 --- /dev/null +++ b/resources/views/components/subscribe-button.blade.php @@ -0,0 +1,102 @@ +
{ + this.open = false; + this.message = ''; + }, 3000); + } + } catch (error) { + this.message = 'An error occurred. Please try again.'; + this.success = false; + } finally { + this.loading = false; + } + } +}" class="relative"> + {{-- Subscribe button --}} + + + {{-- Dropdown form --}} +
+

+ Get notified about service updates and incidents. +

+ +
+ + +
+ + {{-- Status message --}} +
+
+
diff --git a/resources/views/filament/brand-logo.blade.php b/resources/views/filament/brand-logo.blade.php index 25fbc2b8..8cadb59f 100644 --- a/resources/views/filament/brand-logo.blade.php +++ b/resources/views/filament/brand-logo.blade.php @@ -1 +1,24 @@ - +@php + $customLogo = null; + $siteName = null; + $logoHeight = 32; + try { + $branding = app(\Cachet\Settings\BrandingSettings::class); + $customLogo = $branding->header_logo ?: null; + $logoHeight = $branding->header_logo_height ?? 32; + $appSettings = app(\Cachet\Settings\AppSettings::class); + $siteName = $appSettings->name ?: null; + } catch (\Throwable) { + } +@endphp + +@if($customLogo) + {{ $siteName ?: 'Status Page' }} +@elseif($siteName) +
{{ $siteName }}
+@else + +@endif diff --git a/resources/views/filament/pages/integrations/uptime-kuma.blade.php b/resources/views/filament/pages/integrations/uptime-kuma.blade.php new file mode 100644 index 00000000..fa6bed7c --- /dev/null +++ b/resources/views/filament/pages/integrations/uptime-kuma.blade.php @@ -0,0 +1,119 @@ + + +
+ {{ $this->form }} + +
+ + Test Connection + + + + Sync Monitors + + + + Sync Status Only + +
+
+ + + + Linked Components + + + Components that are linked to Uptime Kuma monitors. + + + @php + $linkedComponents = \Cachet\Models\Component::whereNotNull('meta->uptime_kuma_monitor_id')->get(); + @endphp + + @if($linkedComponents->isEmpty()) +

+ No components are linked to Uptime Kuma monitors yet. Use the "Sync Monitors" button above to import monitors, or manually link components by editing them. +

+ @else +
+ + + + + + + + + + @foreach($linkedComponents as $component) + + + + + + @endforeach + +
ComponentMonitor IDStatus
+ + {{ $component->name }} + + + {{ $component->meta['uptime_kuma_monitor_id'] ?? '-' }} + + +
+
+ @endif +
+ + + + Setup Instructions + + + How to configure Uptime Kuma webhooks for automatic incident creation. + + +
+
    +
  1. + Add a Webhook notification in Uptime Kuma: +
      +
    1. Go to your monitor in Uptime Kuma
    2. +
    3. Click on "Setup Notifications"
    4. +
    5. Add a new notification of type "Webhook"
    6. +
    +
  2. +
  3. + Configure the Webhook: +
      +
    • URL: {{ url('/api/integrations/uptime-kuma/webhook') }}
    • +
    • Content Type: application/json
    • + @if(config('cachet.uptime_kuma.webhook_secret')) +
    • Add Header: X-Webhook-Secret: {{ config('cachet.uptime_kuma.webhook_secret') }}
    • + @endif +
    +
  4. +
  5. + Sync Monitors: +

    Use the "Sync Monitors" button above to import monitors from your Uptime Kuma status page. This will create components for each monitor.

    +
  6. +
  7. + Automatic Incidents: +

    When a monitor goes DOWN, an incident will automatically be created. When it goes UP, the incident will be resolved.

    +
  8. +
+
+
+
diff --git a/routes/api.php b/routes/api.php index fb987671..b8168ffc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,6 +11,7 @@ use Cachet\Http\Controllers\Api\ScheduleController; use Cachet\Http\Controllers\Api\ScheduleUpdateController; use Cachet\Http\Controllers\Api\StatusController; +use Cachet\Http\Controllers\Api\UptimeKumaWebhookController; use Illuminate\Support\Facades\Route; Route::apiResources([ @@ -68,3 +69,31 @@ Route::get('/ping', [GeneralController::class, 'ping'])->name('ping'); Route::get('/version', [GeneralController::class, 'version'])->name('version'); Route::get('/status', StatusController::class)->name('status'); + +/* +* Uptime Kuma Webhook Endpoint +*/ + +Route::prefix('integrations/uptime-kuma')->group(function () { + //webhook info and instructions steps + Route::get('/webhook', function () { + return response()->json([ + 'status' => 'ok', + 'message' => 'Uptime Kuma webhook endpoint ready to receive notifications.', + 'description' => 'This endpoint receives webhook notifications from Uptime Kuma when monitor status changes.', + 'uptime_kuma_url' => config('cachet.uptime_kuma.url'), + 'status_page_slug' => config('cachet.uptime_kuma.status_page_slug'), + 'integration_enabled' => config('cachet.uptime_kuma.enabled'), + 'instructions' => [ + '1. In Uptime Kuma, go to Settings → Notifications', + '2. Add a new notification with type "Webhook"', + '3. Set the URL to: '.url('/api/integrations/uptime-kuma/webhook'), + '4. Set Content-Type to "application/json"', + '5. Enable the notification for your monitors', + ], + ]); + })->name('integrations.uptime-kuma.info'); + Route::post('/webhook', UptimeKumaWebhookController::class) + ->name('integrations.uptime-kuma.webhook') + ->middleware('throttle:60,1'); +}); diff --git a/scheduler.cjs b/scheduler.cjs new file mode 100644 index 00000000..ad5d3cf0 --- /dev/null +++ b/scheduler.cjs @@ -0,0 +1,23 @@ +/** + * Simple scheduler runner for testbench. + * Runs `php vendor/bin/testbench schedule:run` every 60 seconds. + * + */ +const { execSync } = require("child_process"); + +const INTERVAL = 60_000; // 1 minute + +function runSchedule() { + try { + execSync("php vendor/bin/testbench schedule:run --no-interaction 2>&1", { + stdio: "inherit", + cwd: __dirname, + }); + } catch (e) { + console.error("[scheduler] Error running schedule:", e);} +} + +console.log("[scheduler] Starting scheduler (every 60s)..."); + +runSchedule(); +setInterval(runSchedule, INTERVAL); diff --git a/src/Actions/UptimeKuma/HandleUptimeKumaWebhook.php b/src/Actions/UptimeKuma/HandleUptimeKumaWebhook.php new file mode 100644 index 00000000..e435b1be --- /dev/null +++ b/src/Actions/UptimeKuma/HandleUptimeKumaWebhook.php @@ -0,0 +1,350 @@ +isValid()) { + return [ + 'action' => 'ignored', + 'incident' => null, + 'component' => null, + 'reason' => 'Invalid webhook payload', + ]; + } + $component = $this->findComponentByMonitorId($data->getMonitorId()); + + if (! $component) { + return [ + 'action' => 'ignored', + 'incident' => null, + 'component' => null, + 'reason' => 'No component linked to monitor ID: '.$data->getMonitorId(), + ]; + } + + //Handle based on status + if ($data->isDown()) { + return $this->handleDownEvent($data, $component); + } + + if ($data->isUp()) { + return $this->handleUpEvent($data, $component); + } + + if ($data->isMaintenance()) { + return $this->handleMaintenanceEvent($data, $component); + } + return [ + 'action' => 'ignored', + 'incident' => null, + 'component' => $component, + 'reason' => 'Pending status ignored', + ]; + } + + /** + * Find a component by its linked Uptime Kuma monitor ID. + */ + protected function findComponentByMonitorId(int $monitorId): ?Component + { + return Component::query() + ->where('meta->uptime_kuma_monitor_id', $monitorId) + ->first(); + } + + /** + * Handle a DOWN event from Uptime Kuma. + */ + protected function handleDownEvent(UptimeKumaWebhookData $data, Component $component): array + { + // Check if there's already an active incident for this component from Uptime Kuma + $existingIncident = $this->findActiveUptimeKumaIncident($component); + + if ($existingIncident) { + return [ + 'action' => 'already_exists', + 'incident' => $existingIncident, + 'component' => $component, + 'reason' => 'Active incident already exists', + ]; + } + $incident = $this->createIncident($data, $component); + $component->update(['status' => ComponentStatusEnum::major_outage]); + $this->updateHeartbeatMeta($component, $data); + + return [ + 'action' => 'incident_created', + 'incident' => $incident, + 'component' => $component, + ]; + } + + /** + * Handle an UP event from Uptime Kuma. + */ + protected function handleUpEvent(UptimeKumaWebhookData $data, Component $component): array + { + $incident = $this->findActiveUptimeKumaIncident($component); + + if (! $incident) { + $component->update(['status' => ComponentStatusEnum::operational]); + } else { + //resolve the incident + $this->resolveIncident($incident, $data); + $component->update(['status' => ComponentStatusEnum::operational]); + } + + $this->updateHeartbeatMeta($component, $data); + + return [ + 'action' => $incident ? 'incident_resolved' : 'no_incident_to_resolve', + 'incident' => $incident ? $incident->fresh() : null, + 'component' => $component, + ]; + } + + /** + * Handle a MAINTENANCE event from Uptime Kuma. + */ + protected function handleMaintenanceEvent(UptimeKumaWebhookData $data, Component $component): array + { + $component->update(['status' => ComponentStatusEnum::under_maintenance]); + + return [ + 'action' => 'maintenance_status_set', + 'incident' => null, + 'component' => $component, + ]; + } + + /** + * Find an active incident created by Uptime Kuma for a component. + */ + protected function findActiveUptimeKumaIncident(Component $component): ?Incident + { + return Incident::query() + ->where('external_provider', 'uptime_kuma') + ->whereIn('status', IncidentStatusEnum::unresolved()) + ->whereHas('components', fn ($query) => $query->where('components.id', $component->id)) + ->latest() + ->first(); + } + + /** + * Create a new incident from an Uptime Kuma DOWN event. + */ + protected function createIncident(UptimeKumaWebhookData $data, Component $component): Incident + { + $monitorName = $data->getMonitorName() ?? 'Unknown Service'; + $message = $data->getHeartbeatMessage() ?? 'Service is not responding'; + $monitorUrl = $data->getMonitorUrl(); + $ping = $data->getPing(); + + $occurredAt = $data->getHeartbeatTime() + ? Carbon::parse($data->getHeartbeatTime()) + : Carbon::now(); + + $template = IncidentTemplate::where('slug', self::TEMPLATE_OUTAGE)->first(); + + if ($template) { + $incidentMessage = $template->render([ + 'service_name' => $monitorName, + 'component_name' => $component->name, + 'url' => $monitorUrl, + 'error_message' => $message, + 'ping' => $ping, + 'occurred_at' => $occurredAt->format('F j, Y \a\t g:i A T'), + 'date' => $occurredAt->format('F j, Y'), + 'time' => $occurredAt->format('g:i A T'), + ]); + } else { + $incidentMessage = $this->generateDefaultOutageMessage($monitorName, $component, $message, $monitorUrl, $occurredAt, $ping); + } + + $incident = Incident::create([ + 'guid' => Str::uuid(), + 'external_provider' => 'uptime_kuma', + 'external_id' => (string) $data->getMonitorId(), + 'name' => "Service Disruption: {$component->name}", + 'status' => IncidentStatusEnum::investigating, + 'message' => $incidentMessage, + 'visible' => ResourceVisibilityEnum::guest, + 'stickied' => false, + 'notifications' => $this->shouldSendNotifications(), + 'occurred_at' => $occurredAt, + ]); + + $incident->components()->attach($component->id, [ + 'component_status' => ComponentStatusEnum::major_outage, + ]); + + return $incident; + } + + /** + * Generate a professional default outage message. + */ + protected function generateDefaultOutageMessage( + string $serviceName, + Component $component, + string $errorMessage, + ?string $url, + Carbon $occurredAt, + ?float $ping + ): string { + $message = "## Service Disruption Detected\n\n"; + $message .= "**Affected Service:** {$component->name}\n\n"; + $message .= "**Status:** Investigating\n\n"; + $message .= "**Detected:** {$occurredAt->format('F j, Y \a\t g:i A T')}\n\n"; + $message .= "---\n\n"; + $message .= "### Details\n\n"; + $message .= "Our monitoring systems have detected an issue with **{$serviceName}**.\n\n"; + + if ($url) { + $message .= "- **Endpoint:** `{$url}`\n"; + } + + $message .= "- **Error:** {$errorMessage}\n"; + + if ($ping !== null) { + $message .= "- **Last Response Time:** {$ping}ms\n"; + } + + $message .= "\n---\n\n"; + $message .= "### What's Next?\n\n"; + $message .= "Our team has been alerted and is actively investigating the issue. "; + $message .= "We will provide updates as more information becomes available.\n\n"; + $message .= "_This incident was automatically detected by our monitoring system._"; + + return $message; + } + + /** + * Resolve an existing incident when service comes back up. + */ + protected function resolveIncident(Incident $incident, UptimeKumaWebhookData $data): void + { + $resolvedAt = $data->getHeartbeatTime() + ? Carbon::parse($data->getHeartbeatTime()) + : Carbon::now(); + $duration = $incident->occurred_at + ? $incident->occurred_at->diffForHumans($resolvedAt, ['syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]) + : 'unknown duration'; + + $template = IncidentTemplate::where('slug', self::TEMPLATE_RECOVERY)->first(); + + if ($template) { + $updateMessage = $template->render([ + 'service_name' => $data->getMonitorName() ?? 'Service', + 'resolved_at' => $resolvedAt->format('F j, Y \a\t g:i A T'), + 'duration' => $duration, + 'date' => $resolvedAt->format('F j, Y'), + 'time' => $resolvedAt->format('g:i A T'), + ]); + } else { + $updateMessage = $this->generateDefaultRecoveryMessage($resolvedAt, $duration); + } + $incident->updates()->create([ + 'status' => IncidentStatusEnum::fixed, + 'message' => $updateMessage, + ]); + $incident->update([ + 'status' => IncidentStatusEnum::fixed, + ]); + $incident->components()->updateExistingPivot( + $incident->components->pluck('id')->toArray(), + ['component_status' => ComponentStatusEnum::operational] + ); + } + + /** + * Generate a professional default recovery message. + */ + protected function generateDefaultRecoveryMessage(Carbon $resolvedAt, string $duration): string + { + $message = "## Service Restored\n\n"; + $message .= "**Status:** Resolved\n\n"; + $message .= "**Resolved:** {$resolvedAt->format('F j, Y \a\t g:i A T')}\n\n"; + $message .= "**Total Downtime:** {$duration}\n\n"; + $message .= "---\n\n"; + $message .= "The service has been restored and is now operating normally. "; + $message .= "We apologize for any inconvenience this may have caused.\n\n"; + $message .= "If you continue to experience issues, please contact our support team.\n\n"; + $message .= "_This update was automatically generated by our monitoring system._"; + + return $message; + } + + /** + * Check if notifications should be sent. + * Uses database settings with fallback to config. + */ + protected function shouldSendNotifications(): bool + { + try { + $settings = app(IntegrationSettings::class); + + return $settings->uptime_kuma_send_notifications; + } catch (\Exception $e) { + Log::debug('IntegrationSettings not available for notifications check'); + + return config('cachet.uptime_kuma.send_notifications', true); + } + } + + /** + * Update component meta with heartbeat data from webhook event. + * This ensures response time and last check info are always current. + */ + protected function updateHeartbeatMeta(Component $component, UptimeKumaWebhookData $data): void + { + $meta = $component->meta ?? []; + + if ($data->getHeartbeatTime()) { + $meta['uptime_kuma_last_heartbeat'] = $data->getHeartbeatTime(); + } + + if ($data->getPing() !== null) { + $meta['uptime_kuma_last_ping'] = $data->getPing(); + } + + // Append to recent heartbeat history (last 30) + $heartbeats = $meta['heartbeats'] ?? []; + $heartbeats[] = [ + 'status' => $data->getStatus() === UptimeKumaWebhookData::STATUS_UP ? 1 : 0, + 'ping' => $data->getPing(), + 'time' => $data->getHeartbeatTime() ?? now()->toIso8601String(), + ]; + $meta['heartbeats'] = array_slice($heartbeats, -30); + + $meta['uptime_kuma_last_sync'] = now()->toIso8601String(); + + $component->update(['meta' => $meta]); + } +} diff --git a/src/Actions/UptimeKuma/LinkComponentToMonitor.php b/src/Actions/UptimeKuma/LinkComponentToMonitor.php new file mode 100644 index 00000000..8cf3298d --- /dev/null +++ b/src/Actions/UptimeKuma/LinkComponentToMonitor.php @@ -0,0 +1,42 @@ +meta ?? []; + $meta['uptime_kuma_monitor_id'] = $monitorId; + + $component->update(['meta' => $meta]); + + return $component->fresh(); + } + + /** + * Unlink a component from its Uptime Kuma monitor. + */ + public function unlink(Component $component): Component + { + $meta = $component->meta ?? []; + unset($meta['uptime_kuma_monitor_id']); + + $component->update(['meta' => $meta]); + + return $component->fresh(); + } + + /** + * Get the Uptime Kuma monitor ID for a component. + */ + public function getMonitorId(Component $component): ?int + { + return $component->meta['uptime_kuma_monitor_id'] ?? null; + } +} diff --git a/src/Actions/UptimeKuma/SyncMonitorsFromUptimeKuma.php b/src/Actions/UptimeKuma/SyncMonitorsFromUptimeKuma.php new file mode 100644 index 00000000..88f594e4 --- /dev/null +++ b/src/Actions/UptimeKuma/SyncMonitorsFromUptimeKuma.php @@ -0,0 +1,414 @@ + ComponentStatusEnum::major_outage, // DOWN + 1 => ComponentStatusEnum::operational, // UP + 2 => ComponentStatusEnum::unknown, // PENDING + 3 => ComponentStatusEnum::under_maintenance, // MAINTENANCE + ]; + + public function __construct( + protected UptimeKumaClient $client + ) {} + + /** + * Sync all groups and monitors from Uptime Kuma to Cachet. + * + * @return array{groups_created: int, groups_updated: int, components_created: int, components_updated: int, errors: array} + */ + public function handle(bool $updateStatus = true): array + { + $result = [ + 'groups_created' => 0, + 'groups_updated' => 0, + 'components_created' => 0, + 'components_updated' => 0, + 'total_synced' => 0, + 'errors' => [], + ]; + + if (! config('cachet.uptime_kuma.enabled', true)) { + $result['errors'][] = 'Uptime Kuma integration is disabled'; + + return $result; + } + if (! $this->client->ping()) { + $result['errors'][] = 'Cannot connect to Uptime Kuma at '.$this->client->getBaseUrl(); + + return $result; + } + $groups = $this->client->getGroupsWithMonitors(); + + if (empty($groups)) { + $result['errors'][] = 'No groups/monitors found. Make sure you have a public status page configured in Uptime Kuma with the slug: '.$this->client->getStatusPageSlug(); + + return $result; + } + + Log::info('Syncing from Uptime Kuma', [ + 'groups' => count($groups), + 'monitors' => array_sum(array_map(fn ($g) => count($g['monitors']), $groups)), + ]); + + foreach ($groups as $group) { + try { + $syncResult = $this->syncGroup($group, $updateStatus); + $result['groups_created'] += $syncResult['group_created'] ? 1 : 0; + $result['groups_updated'] += $syncResult['group_updated'] ? 1 : 0; + $result['components_created'] += $syncResult['components_created']; + $result['components_updated'] += $syncResult['components_updated']; + $result['total_synced'] += $syncResult['components_created'] + $syncResult['components_updated']; + } catch (\Exception $e) { + $result['errors'][] = "Failed to sync group {$group['id']}: ".$e->getMessage(); + Log::error('Failed to sync Uptime Kuma group', [ + 'group_id' => $group['id'], + 'error' => $e->getMessage(), + ]); + } + } + + Log::info('sync completed', $result); + + return $result; + } + + /** + * Sync a single group and its monitors. + * + * @return array{group_created: bool, group_updated: bool, components_created: int, components_updated: int} + */ + protected function syncGroup(array $group, bool $updateStatus): array + { + $result = [ + 'group_created' => false, + 'group_updated' => false, + 'components_created' => 0, + 'components_updated' => 0, + ]; + + $uptimeKumaGroupId = $group['id']; + $componentGroup = ComponentGroup::query() + ->whereJsonContains('meta->uptime_kuma_group_id', $uptimeKumaGroupId) + ->first(); + + if (! $componentGroup) { + $componentGroup = ComponentGroup::query() + ->whereRaw("json_extract(meta, '$.uptime_kuma_group_id') = ?", [$uptimeKumaGroupId]) + ->first(); + } + + if ($componentGroup) { + $componentGroup->update([ + 'name' => $group['name'], + 'order' => $group['weight'], + 'meta' => array_merge($componentGroup->meta ?? [], [ + 'uptime_kuma_group_id' => $uptimeKumaGroupId, + 'uptime_kuma_last_sync' => now()->toIso8601String(), + ]), + ]); + $result['group_updated'] = true; + } else { + $componentGroup = ComponentGroup::create([ + 'name' => $group['name'], + 'order' => $group['weight'], + 'visible' => ResourceVisibilityEnum::guest, + 'collapsed' => ComponentGroupVisibilityEnum::expanded, + 'meta' => [ + 'uptime_kuma_group_id' => $uptimeKumaGroupId, + 'uptime_kuma_last_sync' => now()->toIso8601String(), + ], + ]); + $result['group_created'] = true; + } + + foreach ($group['monitors'] as $monitor) { + try { + $syncResult = $this->syncMonitor($monitor, $componentGroup->id, $updateStatus); + if ($syncResult === 'created') { + $result['components_created']++; + } else { + $result['components_updated']++; + } + } catch (\Exception $e) { + Log::error('Failed to sync monitor', [ + 'monitor_id' => $monitor['id'], + 'error' => $e->getMessage(), + ]); + } + } + + return $result; + } + + /** + * Sync a single monitor to a Cachet component. + */ + protected function syncMonitor(array $monitor, int $componentGroupId, bool $updateStatus): string + { + $monitorId = $monitor['id']; + $component = Component::query() + ->whereRaw("json_extract(meta, '$.uptime_kuma_monitor_id') = ?", [$monitorId]) + ->first(); + + if ($component) { + $updates = [ + 'component_group_id' => $componentGroupId, + ]; + + if ($updateStatus) { + $updates['status'] = $this->mapStatus($monitor['status']); + } + $meta = $component->meta ?? []; + $meta['uptime_kuma_monitor_id'] = $monitorId; + $meta['uptime_kuma_type'] = $monitor['type'] ?? 'http'; + $meta['uptime_kuma_last_sync'] = now()->toIso8601String(); + if (isset($monitor['uptime_24h'])) { + $meta['uptime_kuma_uptime_24h'] = $monitor['uptime_24h']; + } + if (isset($monitor['certExpiryDaysRemaining'])) { + $meta['ssl_expiry_days'] = $monitor['certExpiryDaysRemaining']; + } + if (isset($monitor['heartbeats']) && ! empty($monitor['heartbeats'])) { + $meta['heartbeats'] = $monitor['heartbeats']; + } + $updates['meta'] = $meta; + + $updates['description'] = $this->generateDescription($monitor); + if (! empty($monitor['url']) && empty($component->link)) { + $updates['link'] = $monitor['url']; + } + + $component->update($updates); + + return 'updated'; + } + + $meta = [ + 'uptime_kuma_monitor_id' => $monitorId, + 'uptime_kuma_type' => $monitor['type'] ?? 'http', + 'uptime_kuma_last_sync' => now()->toIso8601String(), + ]; + + if (isset($monitor['uptime_24h'])) { + $meta['uptime_kuma_uptime_24h'] = $monitor['uptime_24h']; + } + + if (isset($monitor['certExpiryDaysRemaining'])) { + $meta['ssl_expiry_days'] = $monitor['certExpiryDaysRemaining']; + } + if (isset($monitor['heartbeats']) && ! empty($monitor['heartbeats'])) { + $meta['heartbeats'] = $monitor['heartbeats']; + } + + Component::create([ + 'name' => $monitor['name'] ?? "Monitor {$monitorId}", + 'description' => $this->generateDescription($monitor), + 'link' => $monitor['url'] ?? null, + 'status' => $this->mapStatus($monitor['status']), + 'component_group_id' => $componentGroupId, + 'enabled' => true, + 'order' => $monitorId, + 'meta' => $meta, + ]); + + return 'created'; + } + + /** + * Map Uptime Kuma status to Cachet component status. + */ + protected function mapStatus(int $status): ComponentStatusEnum + { + return self::STATUS_MAP[$status] ?? ComponentStatusEnum::unknown; + } + + /** + * Generate a description for the component based on monitoring data + */ + protected function generateDescription(array $monitor): string + { + $parts = []; + if (isset($monitor['uptime_24h'])) { + $uptime = round($monitor['uptime_24h'] * 100, 2); + $parts[] = "24h Uptime: {$uptime}%"; + } + if (isset($monitor['certExpiryDaysRemaining']) && $monitor['certExpiryDaysRemaining'] > 0) { + $days = $monitor['certExpiryDaysRemaining']; + $parts[] = "SSL: {$days} days remaining"; + } + if (empty($parts)) { + return 'Monitoring active. Status data will appear after first heartbeat.'; + } + + return implode(' | ', $parts); + } + + /** + * Sync status and heartbeat data for existing linked components. + * + * + * @return array{updated: int, errors: array} + */ + public function syncStatusOnly(): array + { + $result = [ + 'updated' => 0, + 'errors' => [], + ]; + + if (! config('cachet.uptime_kuma.enabled', true)) { + $result['errors'][] = 'Uptime Kuma integration is disabled'; + + return $result; + } + + //get all components linked to Uptime Kuma + $components = Component::query() + ->whereNotNull('meta->uptime_kuma_monitor_id') + ->get(); + + if ($components->isEmpty()) { + return $result; + } + $monitors = collect($this->client->getMonitors())->keyBy('id'); + + foreach ($components as $component) { + $monitorId = $component->meta['uptime_kuma_monitor_id'] ?? null; + + if (! $monitorId) { + continue; + } + + $monitor = $monitors->get($monitorId); + + if (! $monitor) { + continue; + } + + $newStatus = $this->mapStatus($monitor['status']); + + $meta = $component->meta ?? []; + $meta['uptime_kuma_monitor_id'] = $monitorId; + $meta['uptime_kuma_last_sync'] = now()->toIso8601String(); + + if (isset($monitor['uptime_24h'])) { + $meta['uptime_kuma_uptime_24h'] = $monitor['uptime_24h']; + } + + if (isset($monitor['certExpiryDaysRemaining'])) { + $meta['ssl_expiry_days'] = $monitor['certExpiryDaysRemaining']; + } + if (isset($monitor['heartbeats']) && ! empty($monitor['heartbeats'])) { + $meta['heartbeats'] = $monitor['heartbeats']; + } + + $updates = [ + 'status' => $newStatus, + 'meta' => $meta, + 'description' => $this->generateDescription($monitor), + ]; + + $component->update($updates); + + // If monitor is UP, resolve any active incidents created by Uptime Kuma + if ($newStatus === ComponentStatusEnum::operational) { + $this->resolveActiveIncidents($component); + } + + $result['updated']++; + } + + return $result; + } + + /** + * Resolve any active Uptime Kuma incidents for a component that is now operational. + */ + protected function resolveActiveIncidents(Component $component): void + { + $activeIncidents = Incident::query() + ->where('external_provider', 'uptime_kuma') + ->whereIn('status', IncidentStatusEnum::unresolved()) + ->whereHas('components', fn ($query) => $query->where('components.id', $component->id)) + ->get(); + + foreach ($activeIncidents as $incident) { + $resolvedAt = Carbon::now(); + $duration = $incident->occurred_at + ? $incident->occurred_at->diffForHumans($resolvedAt, ['syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]) + : 'unknown duration'; + + $incident->updates()->create([ + 'status' => IncidentStatusEnum::fixed, + 'message' => "## Service Restored\n\n" + . "**Status:** Resolved\n\n" + . "**Resolved:** {$resolvedAt->format('F j, Y \a\t g:i A T')}\n\n" + . "**Total Downtime:** {$duration}\n\n" + . "---\n\n" + . "The service has been restored and is now operating normally.\n\n" + . "_This update was automatically generated during status sync._", + ]); + + $incident->update(['status' => IncidentStatusEnum::fixed]); + + $incident->components()->updateExistingPivot( + $incident->components->pluck('id')->toArray(), + ['component_status' => ComponentStatusEnum::operational] + ); + + Log::info('Auto-resolved incident during sync', [ + 'incident_id' => $incident->id, + 'component_id' => $component->id, + 'duration' => $duration, + ]); + } + } + + /** + * Get all linked components with their current Uptime Kuma status. + */ + public function getLinkedComponents(): Collection + { + return Component::query() + ->whereNotNull('meta->uptime_kuma_monitor_id') + ->get() + ->map(function (Component $component) { + $monitorId = $component->meta['uptime_kuma_monitor_id'] ?? null; + $monitor = $monitorId ? $this->client->getMonitorStatus($monitorId) : null; + + return [ + 'component' => $component, + 'monitor_id' => $monitorId, + 'uptime_kuma_status' => $monitor ? $this->mapStatus($monitor['status']) : null, + 'synced' => $monitor !== null, + ]; + }); + } +} diff --git a/src/CachetCoreServiceProvider.php b/src/CachetCoreServiceProvider.php index d540baed..efaccbb5 100644 --- a/src/CachetCoreServiceProvider.php +++ b/src/CachetCoreServiceProvider.php @@ -5,7 +5,9 @@ use BladeUI\Icons\Factory; use Cachet\Commands\MakeUserCommand; use Cachet\Commands\SendBeaconCommand; +use Cachet\Commands\SyncUptimeKumaCommand; use Cachet\Commands\VersionCommand; +use Cachet\Services\UptimeKuma\UptimeKumaClient; use Cachet\Database\Seeders\DatabaseSeeder; use Cachet\Listeners\SendWebhookListener; use Cachet\Listeners\WebhookCallEventListener; @@ -49,6 +51,7 @@ public function register(): void $this->app->singleton(Cachet::class); $this->app->singleton(ViewManager::class); + $this->app->singleton(UptimeKumaClient::class); } /** @@ -184,6 +187,7 @@ private function registerCommands(): void $this->commands([ MakeUserCommand::class, SendBeaconCommand::class, + SyncUptimeKumaCommand::class, VersionCommand::class, ]); @@ -207,14 +211,17 @@ private function registerSchedules(): void $this->app->booted(function () { $schedule = $this->app->make(\Illuminate\Console\Scheduling\Schedule::class); - $demoMode = fn () => Cachet::demoMode(); + // $demoMode = fn () => Cachet::demoMode(); $schedule->command('cachet:beacon')->daily(); - $schedule->command('db:seed', [ - '--class' => DatabaseSeeder::class, - '--force', - ])->everyThirtyMinutes()->when($demoMode); + //sync Uptime Kuma monitors + if (config('cachet.uptime_kuma.enabled', true) && config('cachet.uptime_kuma.sync_interval', 1) > 0) { + $schedule->command('cachet:sync-uptime-kuma', ['--status-only']) + ->everyMinute() + ->withoutOverlapping() + ->runInBackground(); + } }); } diff --git a/src/Commands/SyncUptimeKumaCommand.php b/src/Commands/SyncUptimeKumaCommand.php new file mode 100644 index 00000000..d4a6a8d9 --- /dev/null +++ b/src/Commands/SyncUptimeKumaCommand.php @@ -0,0 +1,80 @@ +info('Connecting to Uptime Kuma at '.$client->getBaseUrl().'...'); + $this->info("Using status page slug: {$slug}"); + $this->line(''); + + if (! $client->ping()) { + $this->error('Cannot connect to Uptime Kuma. Please check your configuration.'); + $this->line(''); + $this->line('Make sure:'); + $this->line(' 1. Uptime Kuma is running at '.config('cachet.uptime_kuma.url')); + $this->line(' 2. You have a public status page configured with slug "'.$slug.'"'); + $this->line(' 3. The URL is accessible from this server'); + + return self::FAILURE; + } + + $this->info('Connected to Uptime Kuma!'); + $this->line(''); + + if ($this->option('status-only')) { + $this->info('Syncing status only...'); + $result = $sync->syncStatusOnly(); + + $this->info("Updated: {$result['updated']} components"); + } else { + $this->info('Fetching groups and monitors from Uptime Kuma...'); + + $result = $sync->handle(updateStatus: true); + + $this->line(''); + $this->info('Sync completed!'); + $this->line(" Groups created: {$result['groups_created']}"); + $this->line(" Groups updated: {$result['groups_updated']}"); + $this->line(" Components created: {$result['components_created']}"); + $this->line(" Components updated: {$result['components_updated']}"); + $this->line(" Total synced: {$result['total_synced']}"); + + if (! empty($result['errors'])) { + $this->line(''); + $this->warn('Errors:'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + } + } + + return self::SUCCESS; + } +} diff --git a/src/Data/UptimeKuma/UptimeKumaWebhookData.php b/src/Data/UptimeKuma/UptimeKumaWebhookData.php new file mode 100644 index 00000000..fc6ffd15 --- /dev/null +++ b/src/Data/UptimeKuma/UptimeKumaWebhookData.php @@ -0,0 +1,134 @@ +monitor['id'] ?? null; + } + + /** + * Get the monitor name from the payload. + */ + public function getMonitorName(): ?string + { + return $this->monitor['name'] ?? null; + } + + /** + * Get the monitor URL from the payload. + */ + public function getMonitorUrl(): ?string + { + return $this->monitor['url'] ?? null; + } + + /** + * Get the monitor type from the payload. + */ + public function getMonitorType(): ?string + { + return $this->monitor['type'] ?? null; + } + + /** + * Get the heartbeat status. + */ + public function getStatus(): ?int + { + return $this->heartbeat['status'] ?? null; + } + + /** + * Get the heartbeat message. + */ + public function getHeartbeatMessage(): ?string + { + return $this->heartbeat['msg'] ?? $this->msg; + } + + /** + * Get the heartbeat time. + */ + public function getHeartbeatTime(): ?string + { + return $this->heartbeat['time'] ?? null; + } + + /** + * Get the ping/latency value. + */ + public function getPing(): ?float + { + return $this->heartbeat['ping'] ?? null; + } + + /** + * Check if the monitor is down. + */ + public function isDown(): bool + { + return $this->getStatus() === self::STATUS_DOWN; + } + + /** + * Check if the monitor is up. + */ + public function isUp(): bool + { + return $this->getStatus() === self::STATUS_UP; + } + + /** + * Check if the monitor is pending. + */ + public function isPending(): bool + { + return $this->getStatus() === self::STATUS_PENDING; + } + + /** + * Check if the monitor is under maintenance. + */ + public function isMaintenance(): bool + { + return $this->getStatus() === self::STATUS_MAINTENANCE; + } + + /** + * Check if this is a valid Uptime Kuma webhook payload. + */ + public function isValid(): bool + { + return $this->monitor !== null + && $this->heartbeat !== null + && $this->getMonitorId() !== null + && $this->getStatus() !== null; + } +} diff --git a/src/Filament/Pages/Integrations/UptimeKuma.php b/src/Filament/Pages/Integrations/UptimeKuma.php new file mode 100644 index 00000000..ab63f210 --- /dev/null +++ b/src/Filament/Pages/Integrations/UptimeKuma.php @@ -0,0 +1,306 @@ +getSettings(); + + $this->form->fill([ + 'uptime_kuma_url' => $settings->getUptimeKumaUrl(), + 'status_page_slug' => $settings->getUptimeKumaStatusPageSlug(), + 'update_status' => true, + 'auto_incidents' => $settings->uptime_kuma_auto_incidents, + 'auto_resolve' => $settings->uptime_kuma_auto_resolve, + 'send_notifications' => $settings->uptime_kuma_send_notifications, + 'webhook_secret' => $settings->getWebhookSecret() ?? '', + ]); + } + + /** + * Get integration settings instance. + */ + protected function getSettings(): IntegrationSettings + { + return app(IntegrationSettings::class); + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + Section::make('Connection Settings') + ->description('Configure the connection to your Uptime Kuma status page.') + ->schema([ + TextInput::make('uptime_kuma_url') + ->label('Uptime Kuma URL') + ->placeholder('http://localhost:3001') + ->url() + ->required() + ->helperText('The URL of your Uptime Kuma instance.'), + + TextInput::make('status_page_slug') + ->label('Status Page Slug') + ->placeholder('united-codes') + ->required() + ->helperText('The slug of your public status page in Uptime Kuma (found in the URL: /status/YOUR-SLUG).'), + ]), + + Section::make('Sync Options') + ->description('Configure how monitors and groups should be synced.') + ->schema([ + Toggle::make('update_status') + ->label('Sync component status') + ->helperText('Update component status based on monitor status (UP = Operational, DOWN = Major Outage).') + ->default(true), + + Toggle::make('auto_incidents') + ->label('Auto-create incidents') + ->helperText('Automatically create incidents when monitors go down via webhooks.') + ->default(true), + + Toggle::make('auto_resolve') + ->label('Auto-resolve incidents') + ->helperText('Automatically resolve incidents when monitors come back up.') + ->default(true), + + Toggle::make('send_notifications') + ->label('Send notifications') + ->helperText('Send notifications when incidents are auto-created.') + ->default(true), + ]), + + Section::make('Webhook Security') + ->description('Configure webhook authentication between Uptime Kuma and Cachet.') + ->schema([ + TextInput::make('webhook_url') + ->label('Webhook URL') + ->disabled() + ->default(fn () => url('/api/integrations/uptime-kuma/webhook')) + ->helperText('Add this URL as a Webhook notification in Uptime Kuma.'), + + TextInput::make('webhook_secret') + ->label('Webhook Secret') + ->password() + ->revealable() + ->copyable() + ->helperText(fn () => new HtmlString( + 'Copy this secret and add it to Uptime Kuma notification settings.
' + . 'Custom Headers: {"X-Webhook-Secret": "YOUR_SECRET_HERE"}' + )) + ->suffixAction( + Action::make('generate') + ->icon('heroicon-o-arrow-path') + ->tooltip('Generate new secret') + ->requiresConfirmation() + ->modalHeading('Generate New Webhook Secret') + ->modalDescription('This will generate a new secret and invalidate the old one. You will need to update Uptime Kuma with the new secret.') + ->action(fn () => $this->generateWebhookSecret()) + ), + ]), + ]); + } + + /** + * Test the connection to Uptime Kuma + */ + public function testConnection(): void + { + $this->saveSettings(); + + $client = new UptimeKumaClient($this->uptime_kuma_url, $this->status_page_slug); + + if ($client->ping()) { + $data = $client->getStatusPageData(); + + if ($data) { + $groupCount = count($data['publicGroupList'] ?? []); + $monitorCount = array_sum(array_map(fn ($g) => count($g['monitorList'] ?? []), $data['publicGroupList'] ?? [])); + + Notification::make() + ->title('Connection Successful') + ->body("Connected to Uptime Kuma. Found {$groupCount} groups with {$monitorCount} monitors.") + ->success() + ->send(); + } else { + Notification::make() + ->title('Connection Partial') + ->body("Connected to Uptime Kuma but couldn't fetch status page '{$this->status_page_slug}'. Check the slug.") + ->warning() + ->send(); + } + } else { + Notification::make() + ->title('Connection Failed') + ->body('Could not connect to Uptime Kuma. Please check the URL and ensure Uptime Kuma is running.') + ->danger() + ->send(); + } + } + + /** + * Sync monitors and groups from Uptime Kuma. + */ + public function syncMonitors(): void + { + $this->validate(); + $this->saveSettings(); + + $sync = app(SyncMonitorsFromUptimeKuma::class); + $result = $sync->handle(updateStatus: $this->update_status); + + if (! empty($result['errors'])) { + Notification::make() + ->title('Sync Failed') + ->body(implode("\n", $result['errors'])) + ->danger() + ->send(); + + return; + } + + if ($result['total_synced'] === 0) { + Notification::make() + ->title('No Monitors Found') + ->body("No monitors found. Make sure you have a public status page configured in Uptime Kuma with slug: {$this->status_page_slug}") + ->warning() + ->send(); + + return; + } + $settings = $this->getSettings(); + $settings->uptime_kuma_last_sync = now(); + $settings->save(); + + Notification::make() + ->title('Sync Completed!') + ->body("Groups: {$result['groups_created']} created, {$result['groups_updated']} updated. Components: {$result['components_created']} created, {$result['components_updated']} updated.") + ->success() + ->send(); + } + + /** + * Sync status only for existing linked components. + */ + public function syncStatusOnly(): void + { $this->saveSettings(); + + $sync = app(SyncMonitorsFromUptimeKuma::class); + $result = $sync->syncStatusOnly(); + + if (! empty($result['errors'])) { + Notification::make() + ->title('Status Sync Failed') + ->body(implode(', ', $result['errors'])) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Status Synced') + ->body("Updated status for {$result['updated']} components.") + ->success() + ->send(); + } + + /** + * Save current form values to persistent database settings. + */ + protected function saveSettings(): void + { + $settings = $this->getSettings(); + + $settings->uptime_kuma_url = $this->uptime_kuma_url; + $settings->uptime_kuma_status_page_slug = $this->status_page_slug; + $settings->uptime_kuma_auto_incidents = $this->auto_incidents; + $settings->uptime_kuma_auto_resolve = $this->auto_resolve; + $settings->uptime_kuma_send_notifications = $this->send_notifications; + + if (! empty($this->webhook_secret)) { + $settings->uptime_kuma_webhook_secret = $this->webhook_secret; + } + + $settings->save(); + config([ + 'cachet.uptime_kuma.url' => $this->uptime_kuma_url, + 'cachet.uptime_kuma.status_page_slug' => $this->status_page_slug, + 'cachet.uptime_kuma.auto_resolve' => $this->auto_resolve, + 'cachet.uptime_kuma.send_notifications' => $this->send_notifications, + 'cachet.uptime_kuma.webhook_secret' => $settings->getWebhookSecret(), + ]); + } + + /** + * Generate a new webhook secret. + */ + public function generateWebhookSecret(): void + { + $settings = $this->getSettings(); + $newSecret = $settings->generateWebhookSecret(); + + $this->webhook_secret = $newSecret; + config(['cachet.uptime_kuma.webhook_secret' => $newSecret]); + + Notification::make() + ->title('Webhook Secret Generated') + ->body('New secret generated. Copy it and update Uptime Kuma notification settings.') + ->success() + ->send(); + } +} diff --git a/src/Filament/Pages/Settings/ManageBranding.php b/src/Filament/Pages/Settings/ManageBranding.php new file mode 100644 index 00000000..ac3acb54 --- /dev/null +++ b/src/Filament/Pages/Settings/ManageBranding.php @@ -0,0 +1,183 @@ +components([ + Section::make('Header') + ->description('Customize the status page header appearance.') + ->columns(2) + ->schema([ + FileUpload::make('header_logo') + ->image() + ->imageEditor() + ->label('Header Logo') + ->helperText('Upload a custom logo for the header. Overrides the default site name / app banner in the status page header.') + ->disk('public') + ->columnSpanFull(), + + TextInput::make('header_logo_height') + ->label('Logo Height (px)') + ->numeric() + ->minValue(16) + ->maxValue(120) + ->default(32) + ->suffix('px'), + + ColorPicker::make('header_bg_color') + ->label('Background Color') + ->helperText('Leave empty to use the default theme color.'), + + ColorPicker::make('header_text_color') + ->label('Text Color') + ->helperText('Leave empty for default text color.'), + + Toggle::make('show_subscribe_button') + ->label('Show Subscribe Button') + ->helperText('Display the subscribe button in the header.') + ->default(true), + + Toggle::make('show_dashboard_link') + ->label('Show Dashboard Link') + ->helperText('Display the admin dashboard login link.') + ->default(true), + + KeyValue::make('header_links') + ->label('Extra Navigation Links') + ->keyLabel('Label') + ->valueLabel('URL') + ->helperText('Add custom links to the header navigation bar (e.g. "Documentation" → "https://docs.example.com").') + ->columnSpanFull(), + ]), + + Section::make('Footer') + ->description('Customize the status page footer appearance.') + ->columns(2) + ->schema([ + ColorPicker::make('footer_bg_color') + ->label('Background Color') + ->helperText('Leave empty to use the default.'), + + ColorPicker::make('footer_text_color') + ->label('Text Color') + ->helperText('Leave empty for default text color.'), + + TextInput::make('footer_copyright') + ->label('Copyright Text') + ->placeholder('© 2026 My Company. All rights reserved.') + ->helperText('Custom copyright / legal notice shown in the footer.') + ->columnSpanFull(), + + Toggle::make('show_cachet_branding') + ->label('Show "Powered by Cachet"') + ->helperText('Display the Cachet branding badge in the footer.') + ->default(true), + + KeyValue::make('footer_links') + ->label('Footer Links') + ->keyLabel('Label') + ->valueLabel('URL') + ->helperText('Add custom links to the footer (e.g. "Privacy Policy" → "/privacy").') + ->columnSpanFull(), + ]), + + Section::make('General Branding') + ->description('Global look-and-feel overrides for the entire status page.') + ->columns(2) + ->schema([ + ColorPicker::make('page_bg_color') + ->label('Page Background Color') + ->helperText('Override the page background color.'), + + TextInput::make('favicon_url') + ->label('Favicon URL') + ->placeholder('/favicon.ico') + ->helperText('URL to a custom favicon. Supports absolute or site-relative paths.'), + + Textarea::make('custom_css') + ->label('Additional Custom CSS') + ->rows(6) + ->extraAttributes(['class' => 'font-mono']) + ->helperText('Raw CSS injected after all other styles. Use this for advanced tweaks.') + ->columnSpanFull(), + ]), + ]); + } + + /** + * Transform data before it is saved to the database. + */ + protected function mutateFormDataBeforeSave(array $data): array + { + if (isset($data['header_links']) && is_array($data['header_links'])) { + $links = []; + foreach ($data['header_links'] as $label => $url) { + $links[] = ['label' => $label, 'url' => $url]; + } + $data['header_links'] = json_encode($links); + } + + if (isset($data['footer_links']) && is_array($data['footer_links'])) { + $links = []; + foreach ($data['footer_links'] as $label => $url) { + $links[] = ['label' => $label, 'url' => $url]; + } + $data['footer_links'] = json_encode($links); + } + + return $data; + } + + /** + * Transform data after it is loaded from the database. + */ + protected function mutateFormDataBeforeFill(array $data): array + { + if (! empty($data['header_links']) && is_string($data['header_links'])) { + $links = json_decode($data['header_links'], true) ?: []; + $data['header_links'] = collect($links)->pluck('url', 'label')->toArray(); + } else { + $data['header_links'] = []; + } + + if (! empty($data['footer_links']) && is_string($data['footer_links'])) { + $links = json_decode($data['footer_links'], true) ?: []; + $data['footer_links'] = collect($links)->pluck('url', 'label')->toArray(); + } else { + $data['footer_links'] = []; + } + + return $data; + } +} diff --git a/src/Filament/Resources/Components/ComponentResource.php b/src/Filament/Resources/Components/ComponentResource.php index 63fe4958..71e024cf 100644 --- a/src/Filament/Resources/Components/ComponentResource.php +++ b/src/Filament/Resources/Components/ComponentResource.php @@ -66,6 +66,24 @@ public static function form(Schema $schema): Schema Toggle::make('enabled') ->required(), ]), + + // Uptime Kuma Integration Section + Section::make('Uptime Kuma Integration') + ->description('Link this component to an Uptime Kuma monitor for automatic incident creation.') + ->collapsible() + ->collapsed() + ->schema([ + TextInput::make('uptime_kuma_monitor_id') + ->label('Uptime Kuma Monitor ID') + ->helperText('Enter the monitor ID from Uptime Kuma. Find it in the monitor\'s URL (e.g., /dashboard/1 → ID is 1).') + ->numeric() + ->minValue(1) + ->afterStateHydrated(function (TextInput $component, ?Component $record) { + if ($record) { + $component->state($record->meta['uptime_kuma_monitor_id'] ?? null); + } + }), + ]), ]); } diff --git a/src/Filament/Resources/Components/Pages/CreateComponent.php b/src/Filament/Resources/Components/Pages/CreateComponent.php index 2efeda23..35f7ee26 100644 --- a/src/Filament/Resources/Components/Pages/CreateComponent.php +++ b/src/Filament/Resources/Components/Pages/CreateComponent.php @@ -8,4 +8,20 @@ class CreateComponent extends CreateRecord { protected static string $resource = ComponentResource::class; + + protected function mutateFormDataBeforeCreate(array $data): array + { + $uptimeKumaMonitorId = $data['uptime_kuma_monitor_id'] ?? null; + + $meta = $data['meta'] ?? []; + + if (! empty($uptimeKumaMonitorId)) { + $meta['uptime_kuma_monitor_id'] = (int) $uptimeKumaMonitorId; + } + + $data['meta'] = $meta; + unset($data['uptime_kuma_monitor_id']); + + return $data; + } } diff --git a/src/Filament/Resources/Components/Pages/EditComponent.php b/src/Filament/Resources/Components/Pages/EditComponent.php index 26f42678..0b8fd4f0 100644 --- a/src/Filament/Resources/Components/Pages/EditComponent.php +++ b/src/Filament/Resources/Components/Pages/EditComponent.php @@ -16,4 +16,27 @@ protected function getHeaderActions(): array DeleteAction::make(), ]; } + /** + * Mutate form data before saving the record. + * Specifically handles the Uptime Kuma Monitor ID, storing it in the meta field + * + + */ + protected function mutateFormDataBeforeSave(array $data): array + { + $uptimeKumaMonitorId = $data['uptime_kuma_monitor_id'] ?? null; + + $meta = $data['meta'] ?? $this->record->meta ?? []; + + if (! empty($uptimeKumaMonitorId)) { + $meta['uptime_kuma_monitor_id'] = (int) $uptimeKumaMonitorId; + } else { + unset($meta['uptime_kuma_monitor_id']); + } + + $data['meta'] = $meta; + unset($data['uptime_kuma_monitor_id']); + + return $data; + } } diff --git a/src/Http/Controllers/Api/UptimeKumaWebhookController.php b/src/Http/Controllers/Api/UptimeKumaWebhookController.php new file mode 100644 index 00000000..98f11538 --- /dev/null +++ b/src/Http/Controllers/Api/UptimeKumaWebhookController.php @@ -0,0 +1,87 @@ +verifyWebhookSecret($request)) { + Log::warning('Uptime Kuma webhook received with invalid secret', [ + 'ip' => $request->ip(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'Invalid webhook secret', + ], Response::HTTP_UNAUTHORIZED); + } + $data = UptimeKumaWebhookData::from($request->all()); + Log::info('Uptime Kuma webhook received', [ + 'monitor_id' => $data->getMonitorId(), + 'monitor_name' => $data->getMonitorName(), + 'status' => $data->getStatus(), + 'message' => $data->getHeartbeatMessage(), + ]); + $result = $this->handler->handle($data); + Log::info('Uptime Kuma webhook processed', [ + 'action' => $result['action'], + 'incident_id' => $result['incident']?->id, + 'component_id' => $result['component']?->id, + ]); + + return response()->json([ + 'success' => true, + 'action' => $result['action'], + 'incident_id' => $result['incident']?->id, + 'component_id' => $result['component']?->id, + 'reason' => $result['reason'] ?? null, + ]); + } + + /** + * Verify the webhook secret if one is configured. + */ + protected function verifyWebhookSecret(Request $request): bool + { + // Check database settings first, then fall back to config/env + $settings = app(IntegrationSettings::class); + $configuredSecret = $settings->getWebhookSecret(); + if (empty($configuredSecret)) { + return true; + } + + // Check the Authorization header + $providedSecret = $request->header('X-Webhook-Secret') + ?? $request->header('Authorization') + ?? $request->query('secret'); + if (str_starts_with($providedSecret ?? '', 'Bearer ')) { + $providedSecret = substr($providedSecret, 7); + } + + return hash_equals($configuredSecret, $providedSecret ?? ''); + } +} diff --git a/src/Models/ComponentGroup.php b/src/Models/ComponentGroup.php index 3a72da1a..3428d35e 100644 --- a/src/Models/ComponentGroup.php +++ b/src/Models/ComponentGroup.php @@ -21,6 +21,7 @@ * @property int $order * @property ComponentGroupVisibilityEnum $collapsed * @property ResourceVisibilityEnum $visible + * @property ?array $meta * @property Collection $components * * @method static ComponentGroupFactory factory($count = null, $state = []) @@ -37,6 +38,7 @@ class ComponentGroup extends Model 'order' => 'int', 'collapsed' => ComponentGroupVisibilityEnum::class, 'visible' => ResourceVisibilityEnum::class, + 'meta' => 'json', ]; /** @var list */ @@ -45,6 +47,7 @@ class ComponentGroup extends Model 'order', 'collapsed', 'visible', + 'meta', ]; /** diff --git a/src/Providers/CustomBrandingServiceProvider.php b/src/Providers/CustomBrandingServiceProvider.php new file mode 100644 index 00000000..e691766c --- /dev/null +++ b/src/Providers/CustomBrandingServiceProvider.php @@ -0,0 +1,162 @@ +app->runningInConsole()) { + return; + } + + $this->app->booted(function () { + $this->registerBranding(); + }); + } + + private function registerBranding(): void + { + try { + $branding = app(BrandingSettings::class); + } catch (\Throwable) { + return; + } + $headerLinks = $branding->getHeaderLinks(); + $footerLinks = $branding->getFooterLinks(); + + $shared = [ + 'brandingHeaderBgColor' => $branding->header_bg_color, + 'brandingHeaderTextColor' => $branding->header_text_color, + 'brandingHeaderLogo' => $branding->header_logo, + 'brandingHeaderLogoHeight' => $branding->header_logo_height, + 'brandingHeaderLinks' => $headerLinks, + 'brandingShowSubscribe' => $branding->show_subscribe_button, + 'brandingShowDashboardLink' => $branding->show_dashboard_link, + 'brandingFooterBgColor' => $branding->footer_bg_color, + 'brandingFooterTextColor' => $branding->footer_text_color, + 'brandingFooterCopyright' => $branding->footer_copyright, + 'brandingShowCachetBranding' => $branding->show_cachet_branding, + 'brandingFooterLinks' => $footerLinks, + 'brandingPageBgColor' => $branding->page_bg_color, + 'brandingFaviconUrl' => $branding->favicon_url, + 'brandingCustomCss' => $branding->custom_css, + ]; + + foreach ($shared as $key => $value) { + view()->share($key, $value); + } + CachetView::registerRenderHook( + RenderHook::STATUS_PAGE_BODY_BEFORE, + fn () => view('cachet::components.branding.styles', $shared)->render() + ); + if ($branding->header_logo) { + CachetView::registerRenderHook( + RenderHook::STATUS_PAGE_NAVIGATION_BEFORE, + function () use ($branding) { + $logoUrl = Storage::url($branding->header_logo); + $height = $branding->header_logo_height; + $style = $branding->header_text_color + ? 'color: '.$branding->header_text_color + : ''; + + return << + document.addEventListener('DOMContentLoaded', function() { + var headerLink = document.querySelector('.flex.items-center.justify-between.border-b a'); + if (headerLink) { + headerLink.innerHTML = ''; + } + }); + + HTML; + } + ); + } + if (count($headerLinks) > 0) { + CachetView::registerRenderHook( + RenderHook::STATUS_PAGE_NAVIGATION_AFTER, + fn () => view('cachet::components.branding.header-links', $shared)->render() + ); + } + + if ($branding->footer_copyright || count($footerLinks) > 0) { + CachetView::registerRenderHook( + RenderHook::STATUS_PAGE_BODY_AFTER, + fn () => view('cachet::components.branding.footer-content', $shared)->render() + ); + } + if ($branding->favicon_url) { + CachetView::registerRenderHook( + RenderHook::STATUS_PAGE_BODY_BEFORE, + function () use ($branding) { + $url = e($branding->favicon_url); + + return << + document.addEventListener('DOMContentLoaded', function() { + var link = document.querySelector("link[rel*='icon']"); + if (link) { link.href = '{$url}'; } + else { + link = document.createElement('link'); + link.rel = 'shortcut icon'; + link.href = '{$url}'; + document.head.appendChild(link); + } + }); + + HTML; + } + ); + } + + $this->registerDashboardBranding($branding, $shared); + } + + + private function registerDashboardBranding(BrandingSettings $branding, array $shared): void + { + $hasAnyStyle = $branding->header_bg_color || $branding->page_bg_color + || $branding->header_text_color || $branding->custom_css; + + if ($hasAnyStyle) { + FilamentView::registerRenderHook( + PanelsRenderHook::HEAD_END, + fn () => view('cachet::components.branding.login-styles', $shared)->render() + ); + } + $footerLinks = $branding->getFooterLinks(); + if ($branding->footer_copyright || count($footerLinks) > 0) { + FilamentView::registerRenderHook( + PanelsRenderHook::FOOTER, + fn () => view('cachet::components.branding.login-footer', $shared)->render() + ); + } + if ($branding->favicon_url) { + FilamentView::registerRenderHook( + PanelsRenderHook::HEAD_END, + function () use ($branding) { + $url = e($branding->favicon_url); + + return ''; + } + ); + } + } +} diff --git a/src/Services/UptimeKuma/UptimeKumaClient.php b/src/Services/UptimeKuma/UptimeKumaClient.php new file mode 100644 index 00000000..62a6506c --- /dev/null +++ b/src/Services/UptimeKuma/UptimeKumaClient.php @@ -0,0 +1,290 @@ + database settings > config/env > defaults + $this->baseUrl = rtrim($this->resolveBaseUrl($baseUrl), '/'); + $this->statusPageSlug = $this->resolveStatusPageSlug($statusPageSlug); + } + + /** + * Resolve the base URL from multiple sources. + */ + protected function resolveBaseUrl(?string $override): string + { + if ($override !== null && $override !== '') { + return $override; + } + + // Try to get from database settings + try { + $settings = app(IntegrationSettings::class); + if ($settings->uptime_kuma_url !== null && $settings->uptime_kuma_url !== '') { + return $settings->uptime_kuma_url; + } + } catch (\Exception $e) { + Log::debug('IntegrationSettings not available, using config fallback', ['error' => $e->getMessage()]); + } + return config('cachet.uptime_kuma.url', 'http://localhost:3001'); + } + + /** + * Resolve the status page slug from multiple sources. + */ + protected function resolveStatusPageSlug(?string $override): string + { + if ($override !== null && $override !== '') { + return $override; + } + try { + $settings = app(IntegrationSettings::class); + if ($settings->uptime_kuma_status_page_slug !== null && $settings->uptime_kuma_status_page_slug !== '') { + return $settings->uptime_kuma_status_page_slug; + } + } catch (\Exception $e) { + Log::debug('IntegrationSettings not available for slug, using config fallback'); + } + return config('cachet.uptime_kuma.status_page_slug', 'default'); + } + + /** + * Get the HTTP client instance. + */ + protected function client(): PendingRequest + { + return Http::baseUrl($this->baseUrl) + ->timeout(30) + ->acceptJson(); + } + + /** + * Check if Uptime Kuma is reachable. + */ + public function ping(): bool + { + try { + $response = $this->client()->get('/api/entry-page'); + + return $response->successful(); + } catch (\Exception $e) { + Log::warning('Uptime Kuma ping failed', ['error' => $e->getMessage()]); + + return false; + } + } + + /** + * Get the base URL. + */ + public function getBaseUrl(): string + { + return $this->baseUrl; + } + + /** + * Get the status page slug. + */ + public function getStatusPageSlug(): string + { + return $this->statusPageSlug; + } + + /** + * Fetch status page data including groups and monitors. + * Returns the publicGroupList which contains groups with their monitors. + * + * @return array{config: array, publicGroupList: array, incident: ?array}|null + */ + public function getStatusPageData(): ?array + { + try { + $response = $this->client()->get("/api/status-page/{$this->statusPageSlug}"); + + if ($response->successful()) { + return $response->json(); + } + + Log::warning('Failed to fetch status page data', [ + 'slug' => $this->statusPageSlug, + 'status' => $response->status(), + ]); + } catch (\Exception $e) { + Log::error('Failed to fetch status page data from Uptime Kuma', [ + 'error' => $e->getMessage(), + ]); + } + + return null; + } + + /** + * Fetch heartbeat data (current status) for all monitors on the status page. + * + * @return array{heartbeatList: array, uptimeList: array}|null + */ + public function getHeartbeatData(): ?array + { + try { + $response = $this->client()->get("/api/status-page/heartbeat/{$this->statusPageSlug}"); + + if ($response->successful()) { + return $response->json(); + } + + Log::warning('Failed to fetch heartbeat data', [ + 'slug' => $this->statusPageSlug, + 'status' => $response->status(), + ]); + } catch (\Exception $e) { + Log::error('Failed to fetch heartbeat data from Uptime Kuma', [ + 'error' => $e->getMessage(), + ]); + } + + return null; + } + + /** + * Get groups with monitors and their current status. + * + * @return array + */ + public function getGroupsWithMonitors(): array + { + // Fetch status page data + $statusPageData = $this->getStatusPageData(); + + if (! $statusPageData || empty($statusPageData['publicGroupList'])) { + Log::info('No public groups found on status page', ['slug' => $this->statusPageSlug]); + + return []; + } + $heartbeatData = $this->getHeartbeatData(); + $heartbeatList = $heartbeatData['heartbeatList'] ?? []; + $uptimeList = $heartbeatData['uptimeList'] ?? []; + + $groups = []; + + foreach ($statusPageData['publicGroupList'] as $group) { + $monitors = []; + + foreach ($group['monitorList'] ?? [] as $monitor) { + $monitorId = $monitor['id']; + $status = 2; // Default to PENDING + $recentHeartbeats = []; + + if (isset($heartbeatList[$monitorId]) && ! empty($heartbeatList[$monitorId])) { + $latestHeartbeat = end($heartbeatList[$monitorId]); + $status = (int) ($latestHeartbeat['status'] ?? 2); + + //Store recent heartbeats for visualization (last 30 entries) + $allHeartbeats = $heartbeatList[$monitorId]; + $recentHeartbeats = array_slice($allHeartbeats, -30); + $recentHeartbeats = array_map(fn ($hb) => [ + 'status' => (int) ($hb['status'] ?? 2), + 'ping' => $hb['ping'] ?? null, + 'time' => $hb['time'] ?? null, + ], $recentHeartbeats); + } + + //certificate info + $certExpiryDays = null; + if (isset($monitor['certExpiryDaysRemaining'])) { + $certExpiryDays = $monitor['certExpiryDaysRemaining']; + } + + $monitors[] = [ + 'id' => $monitorId, + 'name' => $monitor['name'] ?? "Monitor {$monitorId}", + 'type' => $monitor['type'] ?? 'http', + 'url' => $monitor['url'] ?? null, + 'status' => $status, + 'uptime_24h' => $uptimeList["{$monitorId}_24"] ?? null, + 'heartbeats' => $recentHeartbeats, + 'certExpiryDaysRemaining' => $certExpiryDays, + ]; + } + + $groups[] = [ + 'id' => $group['id'], + 'name' => $group['name'] ?? 'Unnamed Group', + 'weight' => $group['weight'] ?? 0, + 'monitors' => $monitors, + ]; + } + + Log::info('Fetched groups and monitors from Uptime Kuma', [ + 'groups_count' => count($groups), + 'total_monitors' => array_sum(array_map(fn ($g) => count($g['monitors']), $groups)), + ]); + + return $groups; + } + + /** + * Get a flat list of all monitors from all groups. + * + * @return array + */ + public function getMonitors(): array + { + $groups = $this->getGroupsWithMonitors(); + $monitors = []; + + foreach ($groups as $group) { + foreach ($group['monitors'] as $monitor) { + $monitors[] = array_merge($monitor, [ + 'group_id' => $group['id'], + 'group_name' => $group['name'], + ]); + } + } + + return $monitors; + } + + /** + * Get a specific monitor's status. + */ + public function getMonitorStatus(int $monitorId): ?array + { + $monitors = $this->getMonitors(); + + foreach ($monitors as $monitor) { + if ($monitor['id'] === $monitorId) { + return $monitor; + } + } + + return null; + } +} diff --git a/src/Settings/BrandingSettings.php b/src/Settings/BrandingSettings.php new file mode 100644 index 00000000..033aaa00 --- /dev/null +++ b/src/Settings/BrandingSettings.php @@ -0,0 +1,70 @@ +header_links)) { + return []; + } + + return json_decode($this->header_links, true) ?: []; + } + + public function getFooterLinks(): array + { + if (empty($this->footer_links)) { + return []; + } + + return json_decode($this->footer_links, true) ?: []; + } +} diff --git a/src/Settings/IntegrationSettings.php b/src/Settings/IntegrationSettings.php new file mode 100644 index 00000000..79b5ec5d --- /dev/null +++ b/src/Settings/IntegrationSettings.php @@ -0,0 +1,116 @@ +uptime_kuma_url + ?? config('cachet.uptime_kuma.url'); + } + + /** + * Get the effective status page slug. + * Falls back to config/env if not set in settings. + */ + public function getUptimeKumaStatusPageSlug(): string + { + return $this->uptime_kuma_status_page_slug + ?? config('cachet.uptime_kuma.status_page_slug') + ?? 'default'; + } + + /** + * Check if Uptime Kuma integration is enabled. + */ + public function isUptimeKumaEnabled(): bool + { + return $this->uptime_kuma_enabled + && config('cachet.uptime_kuma.enabled', true); + } + + /** + * Get the effective webhook secret. + * Checks database first, then falls back to config/env. + */ + public function getWebhookSecret(): ?string + { + return $this->uptime_kuma_webhook_secret + ?? config('cachet.uptime_kuma.webhook_secret'); + } + + /** + * Generate a new random webhook secret. + */ + public function generateWebhookSecret(): string + { + $secret = bin2hex(random_bytes(32)); + $this->uptime_kuma_webhook_secret = $secret; + $this->save(); + + return $secret; + } +} diff --git a/testbench.yaml b/testbench.yaml index cd9a475b..23d3e0ea 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -2,6 +2,7 @@ providers: - Workbench\App\Providers\WorkbenchServiceProvider - Cachet\CachetCoreServiceProvider - Cachet\CachetDashboardServiceProvider + - Cachet\Providers\CustomBrandingServiceProvider - Spatie\LaravelSettings\LaravelSettingsServiceProvider - Spatie\LaravelData\LaravelDataServiceProvider - Laravel\Sanctum\SanctumServiceProvider @@ -23,26 +24,24 @@ migrations: workbench: start: '/' - install: true + install: false discovers: config: true web: true - sync: - - from: public - to: public/vendor/cachethq/cachet + + # sync: + # - from: public + # to: public/vendor/cachethq/cachet build: - - asset-publish - filament:assets - - create-sqlite-db - storage-link - - db:wipe - - migrate:refresh: - --seed: true - --seeder: Cachet\Database\Seeders\DatabaseSeeder + # - create-sqlite-db + # - migrate + # - asset-publish assets: - query-builder-config - - cachet-assets + # - cachet-assets -purge: - directories: - - public/vendor +# purge: +# directories: +# - public/vendor