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)
+
+
+ @if(count($brandingFooterLinks) > 0)
+
+ @foreach($brandingFooterLinks as $link)
+
+ {{ $link['label'] }}
+
+ @endforeach
+
+ @endif
+
+ @if($brandingFooterCopyright)
+
{{ $brandingFooterCopyright }}
+ @endif
+
+@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)
+
+
+ @if(count($brandingFooterLinks) > 0)
+
+ @foreach($brandingFooterLinks as $link)
+
+ {{ $link['label'] }}
+
+ @endforeach
+
+ @endif
+
+ @if($brandingFooterCopyright)
+
{{ $brandingFooterCopyright }}
+ @endif
+
+@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 ($dashboardLoginLink)
- @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 --}}
+
+
+ Subscribe
+
+
+ {{-- 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)
+
+@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 @@
+
+
+
+
+
+
+ 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
+
+
+
+
+ Component
+ Monitor ID
+ Status
+
+
+
+ @foreach($linkedComponents as $component)
+
+
+
+ {{ $component->name }}
+
+
+
+ {{ $component->meta['uptime_kuma_monitor_id'] ?? '-' }}
+
+
+
+
+
+ @endforeach
+
+
+
+ @endif
+
+
+
+
+ Setup Instructions
+
+
+ How to configure Uptime Kuma webhooks for automatic incident creation.
+
+
+
+
+
+ Add a Webhook notification in Uptime Kuma:
+
+ Go to your monitor in Uptime Kuma
+ Click on "Setup Notifications"
+ Add a new notification of type "Webhook"
+
+
+
+ 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
+
+
+
+ Sync Monitors:
+ Use the "Sync Monitors" button above to import monitors from your Uptime Kuma status page. This will create components for each monitor.
+
+
+ Automatic Incidents:
+ When a monitor goes DOWN, an incident will automatically be created. When it goes UP, the incident will be resolved.
+
+
+
+
+
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