Skip to content

Commit 7d1f6f8

Browse files
jbrooksukclaude
andauthored
Component Monitoring (#211)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4589a60 commit 7d1f6f8

16 files changed

Lines changed: 630 additions & 1 deletion
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Cachet\Database\Factories;
4+
5+
use Cachet\Enums\ComponentStatusEnum;
6+
use Cachet\Models\Component;
7+
use Cachet\Models\ComponentCheck;
8+
use Illuminate\Database\Eloquent\Factories\Factory;
9+
10+
/**
11+
* @extends Factory<ComponentCheck>
12+
*/
13+
class ComponentCheckFactory extends Factory
14+
{
15+
protected $model = ComponentCheck::class;
16+
17+
/**
18+
* Define the model's default state.
19+
*
20+
* @return array<string, mixed>
21+
*/
22+
public function definition(): array
23+
{
24+
return [
25+
'component_id' => Component::factory(),
26+
'status' => ComponentStatusEnum::operational,
27+
'successful' => true,
28+
'response_code' => 200,
29+
'response_time' => fake()->numberBetween(20, 800),
30+
'checked_at' => now(),
31+
];
32+
}
33+
}

database/factories/ComponentFactory.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ public function enabled(): self
4040
]);
4141
}
4242

43+
/**
44+
* Create a component that is monitored via a link check.
45+
*/
46+
public function checked(): self
47+
{
48+
return $this->state([
49+
'checked' => true,
50+
'link' => fake()->url(),
51+
]);
52+
}
53+
4354
/**
4455
* Create a component that is disabled
4556
*/
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('components', function (Blueprint $table) {
15+
$table->boolean('checked')->default(false)->after('enabled');
16+
$table->timestamp('checked_at')->nullable()->after('checked');
17+
});
18+
}
19+
20+
/**
21+
* Reverse the migrations.
22+
*/
23+
public function down(): void
24+
{
25+
Schema::table('components', function (Blueprint $table) {
26+
$table->dropColumn([
27+
'checked',
28+
'checked_at',
29+
]);
30+
});
31+
}
32+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::create('component_checks', function (Blueprint $table) {
15+
$table->id();
16+
$table->foreignId('component_id')->constrained('components')->cascadeOnDelete();
17+
$table->unsignedTinyInteger('status');
18+
$table->boolean('successful')->default(false);
19+
$table->unsignedSmallInteger('response_code')->nullable();
20+
$table->unsignedInteger('response_time')->nullable();
21+
$table->timestamp('checked_at');
22+
$table->timestamps();
23+
});
24+
}
25+
26+
/**
27+
* Reverse the migrations.
28+
*/
29+
public function down(): void
30+
{
31+
Schema::dropIfExists('component_checks');
32+
}
33+
};

resources/lang/en/component.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
'group' => 'Group',
1111
'enabled' => 'Enabled',
1212
'created_at' => 'Created at',
13+
'checked' => 'Monitored',
14+
'checked_at' => 'Checked at',
1315
'updated_at' => 'Updated at',
1416
'deleted_at' => 'Deleted at',
1517
],
@@ -27,6 +29,7 @@
2729
'component_group_label' => 'Component Group',
2830
'link_label' => 'Link',
2931
'link_helper' => 'An optional link to the component.',
32+
'checked_label' => 'Whether to periodically check the component.',
3033
],
3134
'status' => [
3235
'operational' => 'Operational',
@@ -36,5 +39,19 @@
3639
'under_maintenance' => 'Under maintenance',
3740
'unknown' => 'Unknown',
3841
],
42+
'checks' => [
43+
'title' => 'Recent checks',
44+
'empty_state' => [
45+
'heading' => 'No checks yet',
46+
'description' => 'Checks will appear here once the component has been monitored.',
47+
],
48+
'headers' => [
49+
'status' => 'Status',
50+
'successful' => 'Successful',
51+
'response_code' => 'Response code',
52+
'response_time' => 'Response time',
53+
'checked_at' => 'Checked at',
54+
],
55+
],
3956

4057
];

src/CachetCoreServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Cachet;
44

55
use BladeUI\Icons\Factory;
6+
use Cachet\Commands\CheckComponentsCommand;
67
use Cachet\Commands\MakeUserCommand;
78
use Cachet\Commands\SendBeaconCommand;
89
use Cachet\Commands\VersionCommand;
@@ -182,6 +183,7 @@ private function registerCommands(): void
182183
{
183184
if ($this->app->runningInConsole()) {
184185
$this->commands([
186+
CheckComponentsCommand::class,
185187
MakeUserCommand::class,
186188
SendBeaconCommand::class,
187189
VersionCommand::class,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Cachet\Commands;
4+
5+
use Cachet\Jobs\CheckComponent;
6+
use Cachet\Models\Component;
7+
use Illuminate\Console\Command;
8+
9+
class CheckComponentsCommand extends Command
10+
{
11+
/**
12+
* The name and signature of the console command.
13+
*
14+
* @var string
15+
*/
16+
protected $signature = 'cachet:check';
17+
18+
/**
19+
* The console command description.
20+
*
21+
* @var string
22+
*/
23+
protected $description = 'Check the status of all components.';
24+
25+
/**
26+
* Execute the console command.
27+
*/
28+
public function handle(): int
29+
{
30+
Component::query()
31+
->enabled()
32+
->checked()
33+
->whereNotNull('link')
34+
->get()
35+
->each(fn (Component $component) => CheckComponent::dispatch($component));
36+
37+
$this->components->success('Component check dispatched.');
38+
39+
return self::SUCCESS;
40+
}
41+
}

src/Data/Checks/CheckResult.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace Cachet\Data\Checks;
4+
5+
use Cachet\Data\BaseData;
6+
use Cachet\Enums\ComponentStatusEnum;
7+
use Illuminate\Http\Client\ConnectionException;
8+
use Illuminate\Http\Client\RequestException;
9+
use Illuminate\Http\Client\Response;
10+
11+
final class CheckResult extends BaseData
12+
{
13+
public function __construct(
14+
public readonly ComponentStatusEnum $status,
15+
public readonly bool $successful,
16+
public readonly ?int $responseCode = null,
17+
public readonly ?int $responseTime = null,
18+
public readonly ?string $error = null,
19+
) {}
20+
21+
/**
22+
* Build a check result from a successful HTTP exchange.
23+
*/
24+
public static function fromResponse(Response $response, int $attempts, ?float $transferTime = null): self
25+
{
26+
$status = match (true) {
27+
$response->successful() && $attempts === 1 => ComponentStatusEnum::operational,
28+
$response->successful() => ComponentStatusEnum::performance_issues,
29+
$response->status() >= 500 => ComponentStatusEnum::major_outage,
30+
$response->status() >= 400 => ComponentStatusEnum::partial_outage,
31+
default => ComponentStatusEnum::operational,
32+
};
33+
34+
return new self(
35+
status: $status,
36+
successful: $response->successful(),
37+
responseCode: $response->status(),
38+
responseTime: $transferTime !== null ? (int) round($transferTime * 1000) : null,
39+
);
40+
}
41+
42+
/**
43+
* Build a check result from a failed HTTP request.
44+
*/
45+
public static function fromException(RequestException|ConnectionException $e): self
46+
{
47+
$status = match (true) {
48+
$e->getCode() >= 500 => ComponentStatusEnum::major_outage,
49+
default => ComponentStatusEnum::partial_outage,
50+
};
51+
52+
return new self(
53+
status: $status,
54+
successful: false,
55+
responseCode: $e->getCode() ?: null,
56+
error: $e->getMessage(),
57+
);
58+
}
59+
60+
/**
61+
* The attributes used to persist this result as a component check.
62+
*
63+
* @return array<string, mixed>
64+
*/
65+
public function toCheckAttributes(): array
66+
{
67+
return [
68+
'status' => $this->status,
69+
'successful' => $this->successful,
70+
'response_code' => $this->responseCode,
71+
'response_time' => $this->responseTime,
72+
];
73+
}
74+
}

src/Filament/Resources/Components/ComponentResource.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Cachet\Filament\Resources\Components\Pages\CreateComponent;
77
use Cachet\Filament\Resources\Components\Pages\EditComponent;
88
use Cachet\Filament\Resources\Components\Pages\ListComponents;
9+
use Cachet\Filament\Resources\Components\RelationManagers\ChecksRelationManager;
910
use Cachet\Models\Component;
1011
use Filament\Actions\BulkActionGroup;
1112
use Filament\Actions\DeleteBulkAction;
@@ -58,6 +59,8 @@ public static function form(Schema $schema): Schema
5859
->label(__('cachet::component.form.link_label'))
5960
->url()
6061
->label(__('cachet::component.form.link_helper')),
62+
Toggle::make('checked')
63+
->label(__('cachet::component.form.checked_label')),
6164
]),
6265

6366
Section::make()->columns(2)->schema([
@@ -98,6 +101,15 @@ public static function table(Table $table): Table
98101
->dateTime()
99102
->sortable()
100103
->toggleable(isToggledHiddenByDefault: true),
104+
IconColumn::make('checked')
105+
->label(__('cachet::component.list.headers.checked'))
106+
->boolean()
107+
->toggleable(isToggledHiddenByDefault: false),
108+
TextColumn::make('checked_at')
109+
->label(__('cachet::component.list.headers.checked_at'))
110+
->dateTime()
111+
->sortable()
112+
->toggleable(isToggledHiddenByDefault: false),
101113
TextColumn::make('updated_at')
102114
->label(__('cachet::component.list.headers.updated_at'))
103115
->dateTime()
@@ -129,7 +141,7 @@ public static function table(Table $table): Table
129141
public static function getRelations(): array
130142
{
131143
return [
132-
//
144+
ChecksRelationManager::class,
133145
];
134146
}
135147

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
namespace Cachet\Filament\Resources\Components\RelationManagers;
4+
5+
use Filament\Resources\RelationManagers\RelationManager;
6+
use Filament\Tables\Columns\IconColumn;
7+
use Filament\Tables\Columns\TextColumn;
8+
use Filament\Tables\Table;
9+
10+
class ChecksRelationManager extends RelationManager
11+
{
12+
protected static string $relationship = 'checks';
13+
14+
public function table(Table $table): Table
15+
{
16+
return $table
17+
->heading(__('cachet::component.checks.title'))
18+
->modelLabel(__('cachet::component.checks.title'))
19+
->columns([
20+
TextColumn::make('status')
21+
->label(__('cachet::component.checks.headers.status'))
22+
->badge()
23+
->sortable(),
24+
IconColumn::make('successful')
25+
->label(__('cachet::component.checks.headers.successful'))
26+
->boolean()
27+
->sortable(),
28+
TextColumn::make('response_code')
29+
->label(__('cachet::component.checks.headers.response_code'))
30+
->placeholder('')
31+
->sortable(),
32+
TextColumn::make('response_time')
33+
->label(__('cachet::component.checks.headers.response_time'))
34+
->placeholder('')
35+
->suffix(' ms')
36+
->sortable(),
37+
TextColumn::make('checked_at')
38+
->label(__('cachet::component.checks.headers.checked_at'))
39+
->dateTime()
40+
->sortable(),
41+
])
42+
->defaultSort('checked_at', 'desc')
43+
->emptyStateHeading(__('cachet::component.checks.empty_state.heading'))
44+
->emptyStateDescription(__('cachet::component.checks.empty_state.description'));
45+
}
46+
}

0 commit comments

Comments
 (0)