Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,14 @@ protected function isAccessible(User $user, ?string $path = null): bool
## HTTP Requests

- Never use the `request()` helper. Inject `Illuminate\Http\Request` into the controller method and read input from `$request` (e.g. `$request->integer('per_page', 15)`).

## The Current User

- Never call `auth()` inside a model or an action. Resolving the current user is the caller's job, and reaching for it deeper down couples domain code to an HTTP session it should know nothing about.
- Models and actions that need a user accept one as a parameter, typed `?Authenticatable`.
- Controllers, Filament pages, jobs, commands and views are the right places to resolve the user, using `Cachet::user()` where a request-bound guard is needed.

## Transactions

- Any action that writes more than once — a model plus its pivots, a change plus its audit record — wraps the writes in `DB::transaction()`, so a failure part-way through cannot leave orphaned or half-populated rows.
- Events describing the completed change are dispatched after the transaction commits, never inside it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('component_status_changes', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('component_id');
$table->unsignedTinyInteger('old_status')->nullable();
$table->unsignedTinyInteger('new_status');
$table->string('source');
$table->nullableMorphs('causer');
$table->text('reason')->nullable();
$table->timestamps();

$table->foreign('component_id')->references('id')->on('components')->cascadeOnDelete();
$table->index(['component_id', 'created_at']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('component_status_changes');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

use Cachet\Enums\ComponentStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* The pivots that link a component to whatever is impacting it.
*
* @var array<string, string>
*/
private const PIVOTS = [
'incident_components' => 'incident_id',
'schedule_components' => 'schedule_id',
];

/**
* Run the migrations.
*
* Now that these rows decide what the status page displays, a component may
* only be attached to a given incident or schedule once. Duplicates that
* predate the constraint are collapsed to their most severe impact, which
* is the one that would have won anyway.
*/
public function up(): void
{
foreach (self::PIVOTS as $table => $parentKey) {
$this->deduplicate($table, $parentKey);

Schema::table($table, function (Blueprint $table) use ($parentKey) {
$table->unique([$parentKey, 'component_id']);
});
}
}

/**
* Reverse the migrations.
*/
public function down(): void
{
foreach (self::PIVOTS as $table => $parentKey) {
Schema::table($table, function (Blueprint $table) use ($parentKey) {
$table->dropUnique([$parentKey, 'component_id']);
});
}
}

/**
* Collapse duplicate rows, keeping the most severe impact of each pair.
*/
private function deduplicate(string $table, string $parentKey): void
{
DB::table($table)
->select($parentKey, 'component_id')
->groupBy($parentKey, 'component_id')
->havingRaw('count(*) > 1')
->get()
->each(function (object $duplicate) use ($table, $parentKey) {
$keep = DB::table($table)
->where($parentKey, $duplicate->{$parentKey})
->where('component_id', $duplicate->component_id)
->get()
->sortByDesc(fn (object $row) => [
ComponentStatusEnum::tryFrom((int) $row->component_status)?->severity() ?? 0,
$row->id,
])
->first();

DB::table($table)
->where($parentKey, $duplicate->{$parentKey})
->where('component_id', $duplicate->component_id)
->where('id', '!=', $keep->id)
->delete();
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

use Cachet\Models\Incident;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
/**
* Run the migrations.
*
* An incident's status used to live in its updates and be resolved when
* read; only a `fixed` update ever wrote the column. Now that the column is
* canonical, any incident whose updates moved it on without resolving it
* would revert to whatever status it was opened with. This aligns the
* column with the newest status-bearing update, by the same rule the
* application now keeps it in step with.
*/
public function up(): void
{
$statuses = [];

DB::table('updates')
->where('updateable_type', Relation::getMorphAlias(Incident::class))
->whereNotNull('status')
->orderBy('created_at')
->orderBy('id')
->select(['updateable_id', 'status'])
->chunk(1000, function ($updates) use (&$statuses): void {
foreach ($updates as $update) {
$statuses[$update->updateable_id] = $update->status;
}
});

collect($statuses)
->groupBy(fn (int $status): int => $status, preserveKeys: true)
->each(function ($incidents, int $status): void {
$incidents->keys()->chunk(1000)->each(fn ($ids) => DB::table('incidents')
->whereIn('id', $ids->all())
->where(fn ($query) => $query->where('status', '!=', $status)->orWhereNull('status'))
->update(['status' => $status]));
});
}

/**
* Reverse the migrations.
*
* The previous statuses were never stored, so there is nothing to restore.
*/
public function down(): void
{
//
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

use Cachet\Enums\ComponentStatusEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*
* Component status used to be nullable and was treated as "unknown" at read
* time. Backfill those rows explicitly so the column can become required
* and callers no longer need to carry null-specific logic forever.
*/
public function up(): void
{
DB::table('components')
->whereNull('status')
->update(['status' => ComponentStatusEnum::unknown->value]);

Schema::table('components', function (Blueprint $table) {
$table->unsignedInteger('status')->change();
});
}

/**
* Reverse the migrations.
*
* The original nulls were not preserved, so only the schema nullability can
* be restored here.
*/
public function down(): void
{
Schema::table('components', function (Blueprint $table) {
$table->unsignedInteger('status')->nullable()->change();
});
}
};
6 changes: 6 additions & 0 deletions resources/lang/en/component.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
'under_maintenance' => 'Under maintenance',
'unknown' => 'Unknown',
],
'status_source' => [
'manual' => 'Manual',
'monitor' => 'Monitoring',
'import' => 'Import',
'system' => 'System',
],
'overview' => [
'operational_components_label' => 'Operational components',
'operational_components_description' => 'Components that are fully operational.',
Expand Down
1 change: 1 addition & 0 deletions resources/lang/en/schedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
'action_label' => 'Add component',
'header' => 'Affected components',
'component_label' => 'Component',
'status_label' => 'Status during maintenance',
],
],
'add_update' => [
Expand Down
2 changes: 1 addition & 1 deletion resources/views/components/component-group.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_COMPONENT_GROUPS_BEFORE) }}
@php($groupStatus = $componentGroup->worstComponentStatus())
<li x-data x-disclosure @if ($componentGroup->isExpanded()) default-open @endif>
<li x-data x-disclosure @if ($componentGroup->isExpanded(auth()->user())) default-open @endif>
<button x-disclosure:button class="relative flex w-full items-center justify-between gap-3 py-3 pl-8 pr-4 text-left transition hover:bg-zinc-50/60 dark:hover:bg-white/[0.02] sm:py-4 sm:pl-9 sm:pr-6">
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-zinc-500 sm:left-3">
<x-heroicon-m-chevron-right ::class="$disclosure.isOpen && 'rotate-90'" class="size-3.5 transition" />
Expand Down
6 changes: 3 additions & 3 deletions resources/views/components/component.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@
@focusout="tooltipOpen = false"
class="relative shrink-0">
<div x-ref="badgeAnchor">
@if ($component->incidents_count > 0 && $component->latest_unresolved_incident)
<a href="{{ route('cachet.status-page.incident', [$component->latest_unresolved_incident]) }}" class="inline-flex text-sm font-medium {{ $component->latest_status->getTextColorClasses() }}">
@if ($component->impacting_incident)
<a href="{{ route('cachet.status-page.incident', [$component->impacting_incident]) }}" class="inline-flex text-sm font-medium {{ $component->latest_status->getTextColorClasses() }}">
{{ $component->latest_status->getLabel() }}
</a>
@else
<span class="text-sm font-medium {{ $status->getTextColorClasses() }}">{{ $status->getLabel() }}</span>
<span class="text-sm font-medium {{ $component->latest_status->getTextColorClasses() }}">{{ $component->latest_status->getLabel() }}</span>
@endif
</div>

Expand Down
66 changes: 66 additions & 0 deletions src/Actions/Component/ChangeComponentStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace Cachet\Actions\Component;

use Cachet\Enums\ComponentStatusEnum;
use Cachet\Enums\ComponentStatusSourceEnum;
use Cachet\Events\Components\ComponentStatusWasChanged;
use Cachet\Models\Component;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class ChangeComponentStatus
{
/**
* Change a component's baseline status, recording what caused it.
*
* Every writer of the status column goes through here — the dashboard, the
* API, MCP, the monitor and imports — so the status-changed event fires for
* all of them rather than only the ones that happen to call an action, and
* so there is always a record of who or what asserted the change.
*
* The status and its record are written together, and the event is only
* announced once both have committed.
*
* Anything else the caller was going to save on the component is passed in
* as `$attributes` and written in the same statement, so a single edit is
* one write and one `ComponentUpdated` event rather than two.
*
* @param array<string, mixed> $attributes
*/
public function handle(
Component $component,
ComponentStatusEnum $status,
ComponentStatusSourceEnum $source = ComponentStatusSourceEnum::Manual,
Authenticatable|Model|null $causer = null,
?string $reason = null,
array $attributes = [],
): Component {
$oldStatus = $component->getAttribute('status');
$changed = $oldStatus !== $status;

DB::transaction(function () use ($component, $status, $source, $causer, $reason, $oldStatus, $changed, $attributes): void {
$component->update([...$attributes, 'status' => $status]);

if (! $changed) {
return;
}

$component->statusChanges()->create([
'old_status' => $oldStatus,
'new_status' => $status,
'source' => $source,
'reason' => $reason,
'causer_type' => $causer instanceof Model ? $causer->getMorphClass() : null,
'causer_id' => $causer instanceof Model ? $causer->getKey() : null,
]);
});

if ($changed) {
ComponentStatusWasChanged::dispatch($component, $oldStatus, $status, $source);
}

return $component;
}
}
Loading
Loading