diff --git a/database/migrations/2026_08_12_000001_restore_tags.php b/database/migrations/2026_08_12_000001_restore_tags.php new file mode 100644 index 00000000..e07c29ec --- /dev/null +++ b/database/migrations/2026_08_12_000001_restore_tags.php @@ -0,0 +1,67 @@ +increments('id'); + $table->string('name'); + $table->string('slug')->unique(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('taggables')) { + Schema::create('taggables', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('tag_id')->index(); + $table->morphs('taggable'); + $table->timestamps(); + }); + } + + DB::table('taggables') + ->select('tag_id', 'taggable_id', 'taggable_type') + ->groupBy('tag_id', 'taggable_id', 'taggable_type') + ->havingRaw('count(*) > 1') + ->orderBy('tag_id') + ->orderBy('taggable_id') + ->orderBy('taggable_type') + ->each(function (object $duplicate): void { + $ids = DB::table('taggables') + ->where('tag_id', $duplicate->tag_id) + ->where('taggable_id', $duplicate->taggable_id) + ->where('taggable_type', $duplicate->taggable_type) + ->orderBy('id') + ->pluck('id') + ->all(); + + array_shift($ids); + + DB::table('taggables')->whereIn('id', $ids)->delete(); + }); + + Schema::table('taggables', function (Blueprint $table) { + $table->unique(['tag_id', 'taggable_id', 'taggable_type']); + }); + + DB::table('taggables') + ->where('taggable_type', 'components') + ->update(['taggable_type' => Component::class]); + } + + public function down(): void + { + DB::table('taggables') + ->where('taggable_type', Component::class) + ->update(['taggable_type' => 'components']); + } +}; diff --git a/database/migrations/2026_08_12_000002_add_component_tags_setting.php b/database/migrations/2026_08_12_000002_add_component_tags_setting.php new file mode 100644 index 00000000..37354510 --- /dev/null +++ b/database/migrations/2026_08_12_000002_add_component_tags_setting.php @@ -0,0 +1,11 @@ + $this->migrator->add('app.show_component_tags', false)); + } +}; diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 4029ee51..0fc661b1 100644 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -48,6 +48,8 @@ 'dynamic_favicon_helper' => 'Update the favicon to reflect the current status of your systems.', 'show_component_group_status' => 'Show component group status', 'show_component_group_status_helper' => 'Display each group’s most severe component status on the public status page.', + 'show_component_tags' => 'Show component tags', + 'show_component_tags_helper' => 'Display tags underneath component names on the public status page.', ], 'display_settings_title' => 'Display settings', 'api_settings_title' => 'API settings', diff --git a/resources/views/components/component.blade.php b/resources/views/components/component.blade.php index 6b961f31..78d7d975 100644 --- a/resources/views/components/component.blade.php +++ b/resources/views/components/component.blade.php @@ -28,8 +28,16 @@

{!! $component->formattedDescription() !!}

-
- + + + @if (app(\Cachet\Settings\AppSettings::class)->show_component_tags && $component->tags->isNotEmpty()) +
+ @foreach ($component->tags as $tag) + {{ $tag->name }} + @endforeach +
+ @endif + @endif diff --git a/src/Actions/Component/CreateComponent.php b/src/Actions/Component/CreateComponent.php index 56dbb642..633b3f05 100644 --- a/src/Actions/Component/CreateComponent.php +++ b/src/Actions/Component/CreateComponent.php @@ -12,8 +12,9 @@ class CreateComponent */ public function handle(CreateComponentRequestData $component): Component { - return tap(Component::create($component->except('meta')->toArray()), function (Component $model) use ($component) { + return tap(Component::create($component->except('meta', 'tags')->toArray()), function (Component $model) use ($component) { $model->syncMeta($component->meta ?? []); + $model->syncTags($component->tags); }); } } diff --git a/src/Actions/Component/UpdateComponent.php b/src/Actions/Component/UpdateComponent.php index 503a8c86..23f4c67b 100644 --- a/src/Actions/Component/UpdateComponent.php +++ b/src/Actions/Component/UpdateComponent.php @@ -21,7 +21,7 @@ public function __construct(private ChangeComponentStatus $changeComponentStatus public function handle(Component $component, UpdateComponentRequestData $data, ?Authenticatable $user = null): Component { DB::transaction(function () use ($component, $data, $user): void { - $attributes = $data->except('meta', 'status')->toArray(); + $attributes = $data->except('meta', 'status', 'tags')->toArray(); if ($data->status === null) { $component->update($attributes); @@ -38,6 +38,10 @@ public function handle(Component $component, UpdateComponentRequestData $data, ? if ($data->meta !== null) { $component->syncMeta($data->meta); } + + if ($data->tags !== null) { + $component->syncTags($data->tags); + } }); return $component->fresh(); diff --git a/src/Actions/Incident/CreateIncident.php b/src/Actions/Incident/CreateIncident.php index 62aedcde..51833b3b 100644 --- a/src/Actions/Incident/CreateIncident.php +++ b/src/Actions/Incident/CreateIncident.php @@ -34,7 +34,7 @@ public function handle(CreateIncidentRequestData $data): Incident $incident = DB::transaction(function () use ($data): Incident { return tap(Incident::create(array_merge( ['guid' => Str::uuid()], - $data->except('components', 'meta')->toArray() + $data->except('components', 'meta', 'tags')->toArray() )), function (Incident $incident) use ($data) { $components = collect($data->components) ->mapWithKeys(fn (IncidentComponentRequestData $component) => [ @@ -52,6 +52,7 @@ public function handle(CreateIncidentRequestData $data): Incident } $incident->syncMeta($data->meta ?? []); + $incident->syncTags($data->tags); }); }); diff --git a/src/Actions/Incident/UpdateIncident.php b/src/Actions/Incident/UpdateIncident.php index adeffcc4..9a3c992e 100644 --- a/src/Actions/Incident/UpdateIncident.php +++ b/src/Actions/Incident/UpdateIncident.php @@ -12,12 +12,16 @@ class UpdateIncident */ public function handle(Incident $incident, UpdateIncidentRequestData $data): Incident { - $incident->update($data->except('meta')->toArray()); + $incident->update($data->except('meta', 'tags')->toArray()); if ($data->meta !== null) { $incident->syncMeta($data->meta); } + if ($data->tags !== null) { + $incident->syncTags($data->tags); + } + return $incident->fresh(); } } diff --git a/src/Actions/Metric/CreateMetric.php b/src/Actions/Metric/CreateMetric.php index 2e99ee17..e003fafa 100644 --- a/src/Actions/Metric/CreateMetric.php +++ b/src/Actions/Metric/CreateMetric.php @@ -12,6 +12,6 @@ class CreateMetric */ public function handle(CreateMetricRequestData $data): Metric { - return Metric::create($data->toArray()); + return tap(Metric::create($data->except('tags')->toArray()), fn (Metric $metric) => $metric->syncTags($data->tags)); } } diff --git a/src/Actions/Metric/UpdateMetric.php b/src/Actions/Metric/UpdateMetric.php index add2f82e..3e659c1d 100644 --- a/src/Actions/Metric/UpdateMetric.php +++ b/src/Actions/Metric/UpdateMetric.php @@ -12,7 +12,11 @@ class UpdateMetric */ public function handle(Metric $metric, UpdateMetricRequestData $data): Metric { - $metric->update($data->toArray()); + $metric->update($data->except('tags')->toArray()); + + if ($data->tags !== null) { + $metric->syncTags($data->tags); + } return $metric->fresh(); } diff --git a/src/Actions/Schedule/CreateSchedule.php b/src/Actions/Schedule/CreateSchedule.php index 2bdfe3fe..3f654db2 100644 --- a/src/Actions/Schedule/CreateSchedule.php +++ b/src/Actions/Schedule/CreateSchedule.php @@ -21,7 +21,7 @@ public function handle(CreateScheduleRequestData $data): Schedule { /** @phpstan-ignore-next-line argument.type */ $schedule = DB::transaction(function () use ($data): Schedule { - return tap(Schedule::create($data->except('components', 'meta')->toArray()), function (Schedule $schedule) use ($data) { + return tap(Schedule::create($data->except('components', 'meta', 'tags')->toArray()), function (Schedule $schedule) use ($data) { if ($data->components) { $components = collect($data->components) ->mapWithKeys(fn (ScheduleComponentRequestData $component) => [ @@ -33,6 +33,7 @@ public function handle(CreateScheduleRequestData $data): Schedule } $schedule->syncMeta($data->meta ?? []); + $schedule->syncTags($data->tags); }); }); diff --git a/src/Actions/Schedule/UpdateSchedule.php b/src/Actions/Schedule/UpdateSchedule.php index da071707..0b9ea2ab 100644 --- a/src/Actions/Schedule/UpdateSchedule.php +++ b/src/Actions/Schedule/UpdateSchedule.php @@ -13,12 +13,16 @@ class UpdateSchedule */ public function handle(Schedule $schedule, UpdateScheduleRequestData $data): Schedule { - $schedule->update($data->except('components', 'meta')->toArray()); + $schedule->update($data->except('components', 'meta', 'tags')->toArray()); if ($data->meta !== null) { $schedule->syncMeta($data->meta); } + if ($data->tags !== null) { + $schedule->syncTags($data->tags); + } + if ($data->components) { $components = collect($data->components) ->mapWithKeys(fn (ScheduleComponentRequestData $component) => [ diff --git a/src/Concerns/HasTags.php b/src/Concerns/HasTags.php new file mode 100644 index 00000000..19ae7d52 --- /dev/null +++ b/src/Concerns/HasTags.php @@ -0,0 +1,49 @@ + + */ + public function tags(): MorphToMany + { + return $this->morphToMany(Tag::class, 'taggable'); + } + + /** + * @param list $names + */ + public function syncTags(array $names): void + { + $names = collect($names) + ->filter(fn (string $name): bool => trim($name) !== '') + ->map(fn (string $name): string => trim($name)) + ->unique(fn (string $name): string => mb_strtolower($name)) + ->values(); + + $this->tags()->sync($names->map(fn (string $name): int => Tag::query() + ->where('slug', Str::slug($name)) + ->firstOrCreate(['name' => $name]) + ->id)); + } + + /** + * @param list $names + * @param Builder $query + */ + public function scopeWithAnyTags(Builder $query, array $names): void + { + $slugs = collect($names)->map(fn (string $name): string => Str::slug(trim($name)))->filter(); + + if ($slugs->isNotEmpty()) { + $query->whereHas('tags', fn (Builder $tags) => $tags->whereIn('slug', $slugs)); + } + } +} diff --git a/src/Data/Requests/Component/CreateComponentRequestData.php b/src/Data/Requests/Component/CreateComponentRequestData.php index 11d958b3..2e694f6a 100644 --- a/src/Data/Requests/Component/CreateComponentRequestData.php +++ b/src/Data/Requests/Component/CreateComponentRequestData.php @@ -17,6 +17,8 @@ public function __construct( public readonly ?int $order = null, public readonly bool $enabled = true, public readonly ?int $componentGroupId = null, + /** @var list */ + public readonly array $tags = [], /** @var array|null */ public readonly ?array $meta = null, ) {} @@ -31,6 +33,8 @@ public static function rules(ValidationContext $context): array 'order' => ['int', 'min:0'], 'enabled' => ['boolean'], 'component_group_id' => ['int', 'min:0', Rule::exists('component_groups', 'id')], + 'tags' => ['array'], + 'tags.*' => ['string', 'max:255'], /** * Key/value metadata to store against the resource. * diff --git a/src/Data/Requests/Component/UpdateComponentRequestData.php b/src/Data/Requests/Component/UpdateComponentRequestData.php index bbd6fc2a..036baae9 100644 --- a/src/Data/Requests/Component/UpdateComponentRequestData.php +++ b/src/Data/Requests/Component/UpdateComponentRequestData.php @@ -17,6 +17,8 @@ public function __construct( public readonly ?int $order = null, public readonly ?bool $enabled = null, public readonly ?int $componentGroupId = null, + /** @var list|null */ + public readonly ?array $tags = null, /** @var array|null */ public readonly ?array $meta = null, ) {} @@ -31,6 +33,8 @@ public static function rules(ValidationContext $context): array 'order' => ['int', 'min:0'], 'component_group_id' => ['int', 'min:0', Rule::exists('component_groups', 'id')], 'enabled' => ['boolean'], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['string', 'max:255'], /** * Key/value metadata to store against the resource. * diff --git a/src/Data/Requests/Incident/CreateIncidentRequestData.php b/src/Data/Requests/Incident/CreateIncidentRequestData.php index d3123c04..8d70b52d 100644 --- a/src/Data/Requests/Incident/CreateIncidentRequestData.php +++ b/src/Data/Requests/Incident/CreateIncidentRequestData.php @@ -37,6 +37,8 @@ public function __construct( public readonly ?ComponentStatusEnum $componentStatus = null, #[DataCollectionOf(IncidentComponentRequestData::class)] public readonly ?array $components = null, + /** @var list */ + public readonly array $tags = [], /** @var array|null */ public readonly ?array $meta = null, ) {} @@ -72,6 +74,8 @@ public static function rules(ValidationContext $context): array 'components' => ['array'], 'components.*.id' => ['required', 'int', 'distinct', 'exists:components,id'], 'components.*.status' => ['required', 'int', Rule::enum(ComponentStatusEnum::class)], + 'tags' => ['array'], + 'tags.*' => ['string', 'max:255'], /** * Key/value metadata to store against the resource. * @@ -99,6 +103,7 @@ public function withMessage(string $message): self componentId: $this->componentId, componentStatus: $this->componentStatus, components: $this->components, + tags: $this->tags, meta: $this->meta, ); } diff --git a/src/Data/Requests/Incident/UpdateIncidentRequestData.php b/src/Data/Requests/Incident/UpdateIncidentRequestData.php index 08cfb1ef..825bb32e 100644 --- a/src/Data/Requests/Incident/UpdateIncidentRequestData.php +++ b/src/Data/Requests/Incident/UpdateIncidentRequestData.php @@ -19,6 +19,8 @@ public function __construct( public readonly ?bool $notifications = null, public readonly ?string $occurredAt = null, public readonly ?string $publishedAt = null, + /** @var list|null */ + public readonly ?array $tags = null, /** @var array|null */ public readonly ?array $meta = null, ) {} @@ -40,6 +42,8 @@ public static function rules(ValidationContext $context): array * The date/time to publish the incident, e.g. "2023-11-07 05:31:56" or ISO 8601. While set in the future the incident is hidden from the status page and public API. */ 'published_at' => ['nullable', 'date'], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['string', 'max:255'], /** * Key/value metadata to store against the resource. * diff --git a/src/Data/Requests/Metric/CreateMetricRequestData.php b/src/Data/Requests/Metric/CreateMetricRequestData.php index ebcba0bf..6e4262b8 100644 --- a/src/Data/Requests/Metric/CreateMetricRequestData.php +++ b/src/Data/Requests/Metric/CreateMetricRequestData.php @@ -19,6 +19,8 @@ public function __construct( public readonly ?bool $displayChart = null, public readonly ?int $threshold = null, public readonly ?int $places = null, + /** @var list */ + public readonly array $tags = [], ) {} public static function rules(ValidationContext $context): array @@ -32,6 +34,8 @@ public static function rules(ValidationContext $context): array 'display_chart' => ['nullable', 'boolean'], 'threshold' => ['int', 'min:0', 'max:60', new FactorOfSixty], 'places' => ['int', 'min:0', 'max:4'], + 'tags' => ['array'], + 'tags.*' => ['string', 'max:255'], ]; } } diff --git a/src/Data/Requests/Metric/UpdateMetricRequestData.php b/src/Data/Requests/Metric/UpdateMetricRequestData.php index 0111da37..2b43fda9 100644 --- a/src/Data/Requests/Metric/UpdateMetricRequestData.php +++ b/src/Data/Requests/Metric/UpdateMetricRequestData.php @@ -14,6 +14,8 @@ public function __construct( public readonly ?string $description = null, public readonly ?float $defaultValue = null, public readonly ?int $threshold = null, + /** @var list|null */ + public readonly ?array $tags = null, ) {} public static function rules(ValidationContext $context): array @@ -24,6 +26,8 @@ public static function rules(ValidationContext $context): array 'description' => ['string'], 'default_value' => ['decimal:1,2'], 'threshold' => ['int', 'min:0', 'max:60', new FactorOfSixty], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['string', 'max:255'], ]; } } diff --git a/src/Data/Requests/Schedule/CreateScheduleRequestData.php b/src/Data/Requests/Schedule/CreateScheduleRequestData.php index c7521ec0..99e7c9b3 100644 --- a/src/Data/Requests/Schedule/CreateScheduleRequestData.php +++ b/src/Data/Requests/Schedule/CreateScheduleRequestData.php @@ -25,6 +25,8 @@ public function __construct( public readonly bool $notifications = false, #[DataCollectionOf(ScheduleComponentRequestData::class)] public readonly ?array $components = null, + /** @var list */ + public readonly array $tags = [], /** @var array|null */ public readonly ?array $meta = null, ) {} @@ -50,6 +52,8 @@ public static function rules(ValidationContext $context): array 'components' => ['array'], 'components.*.id' => ['required', 'int', 'distinct', 'exists:components,id'], 'components.*.status' => ['required', 'int', Rule::enum(ComponentStatusEnum::class)], + 'tags' => ['array'], + 'tags.*' => ['string', 'max:255'], /** * Key/value metadata to store against the resource. * diff --git a/src/Data/Requests/Schedule/UpdateScheduleRequestData.php b/src/Data/Requests/Schedule/UpdateScheduleRequestData.php index b528e73b..85944dce 100644 --- a/src/Data/Requests/Schedule/UpdateScheduleRequestData.php +++ b/src/Data/Requests/Schedule/UpdateScheduleRequestData.php @@ -24,6 +24,8 @@ public function __construct( public readonly ?Carbon $publishedAt = null, #[DataCollectionOf(ScheduleComponentRequestData::class)] public readonly ?array $components = null, + /** @var list|null */ + public readonly ?array $tags = null, /** @var array|null */ public readonly ?array $meta = null, ) {} @@ -48,6 +50,8 @@ public static function rules(ValidationContext $context): array 'components' => ['array'], 'components.*.id' => ['required', 'int', 'distinct', 'exists:components,id'], 'components.*.status' => ['required', Rule::enum(ComponentStatusEnum::class)], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['string', 'max:255'], /** * Key/value metadata to store against the resource. * diff --git a/src/Filament/Pages/Settings/ManageCachet.php b/src/Filament/Pages/Settings/ManageCachet.php index 85d5267c..d2a08f51 100644 --- a/src/Filament/Pages/Settings/ManageCachet.php +++ b/src/Filament/Pages/Settings/ManageCachet.php @@ -118,6 +118,9 @@ public function form(Schema $schema): Schema Toggle::make('show_component_group_status') ->label(__('cachet::settings.manage_cachet.toggles.show_component_group_status')) ->helperText(__('cachet::settings.manage_cachet.toggles.show_component_group_status_helper')), + Toggle::make('show_component_tags') + ->label(__('cachet::settings.manage_cachet.toggles.show_component_tags')) + ->helperText(__('cachet::settings.manage_cachet.toggles.show_component_tags_helper')), ]), Section::make(__('cachet::settings.manage_cachet.api_settings_title')) diff --git a/src/Filament/Resources/Components/ComponentResource.php b/src/Filament/Resources/Components/ComponentResource.php index 66189655..cc5b5f3b 100644 --- a/src/Filament/Resources/Components/ComponentResource.php +++ b/src/Filament/Resources/Components/ComponentResource.php @@ -55,6 +55,12 @@ public static function form(Schema $schema): Schema ->searchable() ->preload() ->label(__('cachet::component.form.component_group_label')), + Select::make('tags') + ->relationship('tags', 'name') + ->multiple() + ->searchable() + ->preload() + ->createOptionForm([TextInput::make('name')->required()->maxLength(255)]), TextInput::make('link') ->label(__('cachet::component.form.link_label')) ->url() @@ -92,6 +98,10 @@ public static function table(Table $table): Table TextColumn::make('group.name') ->label(__('cachet::component.list.headers.group')) ->sortable(), + TextColumn::make('tags.name') + ->badge() + ->separator(',') + ->toggleable(), IconColumn::make('enabled') ->label(__('cachet::component.list.headers.enabled')) ->boolean() diff --git a/src/Filament/Resources/Incidents/IncidentResource.php b/src/Filament/Resources/Incidents/IncidentResource.php index 051c00a4..bc507559 100644 --- a/src/Filament/Resources/Incidents/IncidentResource.php +++ b/src/Filament/Resources/Incidents/IncidentResource.php @@ -82,6 +82,12 @@ public static function form(Schema $schema): Schema ->options(ResourceVisibilityEnum::class) ->default(ResourceVisibilityEnum::guest) ->required(), + Select::make('tags') + ->relationship('tags', 'name') + ->multiple() + ->searchable() + ->preload() + ->createOptionForm([TextInput::make('name')->required()->maxLength(255)]), Repeater::make('incidentComponents') ->visibleOn('create') ->relationship() diff --git a/src/Filament/Resources/Metrics/MetricResource.php b/src/Filament/Resources/Metrics/MetricResource.php index 2d1396ed..a026162d 100644 --- a/src/Filament/Resources/Metrics/MetricResource.php +++ b/src/Filament/Resources/Metrics/MetricResource.php @@ -50,6 +50,13 @@ public static function form(Schema $schema): Schema ->label(__('cachet::metric.form.description_label')) ->maxLength(255) ->columnSpanFull(), + Select::make('tags') + ->relationship('tags', 'name') + ->multiple() + ->searchable() + ->preload() + ->createOptionForm([TextInput::make('name')->required()->maxLength(255)]) + ->columnSpanFull(), ToggleButtons::make('default_view') ->label(__('cachet::metric.form.default_view_label')) ->options(MetricViewEnum::class) diff --git a/src/Filament/Resources/Schedules/ScheduleResource.php b/src/Filament/Resources/Schedules/ScheduleResource.php index 49a24c1f..f60af881 100644 --- a/src/Filament/Resources/Schedules/ScheduleResource.php +++ b/src/Filament/Resources/Schedules/ScheduleResource.php @@ -67,6 +67,13 @@ public static function form(Schema $schema): Schema MarkdownEditor::make('message') ->label(__('cachet::schedule.form.message_label')) ->columnSpanFull(), + Select::make('tags') + ->relationship('tags', 'name') + ->multiple() + ->searchable() + ->preload() + ->createOptionForm([TextInput::make('name')->required()->maxLength(255)]) + ->columnSpanFull(), Toggle::make('notifications') ->label(__('cachet::schedule.form.notify_subscribers_label')) ->helperText(__('cachet::schedule.form.notifications_helper')) diff --git a/src/Filters/TagsFilter.php b/src/Filters/TagsFilter.php new file mode 100644 index 00000000..d3a759f6 --- /dev/null +++ b/src/Filters/TagsFilter.php @@ -0,0 +1,32 @@ + $query + */ + public function __invoke(Builder $query, mixed $value, string $property): void + { + $tags = collect((array) $value) + ->flatMap(fn (string $tag): array => explode(',', $tag)) + ->map(fn (string $tag): string => trim($tag)) + ->filter() + ->values() + ->all(); + + if ($tags === []) { + return; + } + + $query->whereHas('tags', function (Builder $tagQuery) use ($tags): void { + $tagQuery->whereIn('slug', collect($tags)->map(fn (string $tag): string => Str::slug($tag))); + }); + } +} diff --git a/src/Http/Controllers/Api/ComponentController.php b/src/Http/Controllers/Api/ComponentController.php index 99b23bd4..6e073436 100644 --- a/src/Http/Controllers/Api/ComponentController.php +++ b/src/Http/Controllers/Api/ComponentController.php @@ -12,6 +12,7 @@ use Cachet\Data\Requests\Component\UpdateComponentRequestData; use Cachet\Enums\ComponentStatusEnum; use Cachet\Filters\MetaFilter; +use Cachet\Filters\TagsFilter; use Cachet\Http\Resources\Component as ComponentResource; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; @@ -42,6 +43,7 @@ class ComponentController extends Controller #[QueryParameter('filter[name]', 'Filter by name.', example: 'My Component')] #[QueryParameter('filter[enabled]', 'Filter by enabled status.', type: 'bool', example: '1')] #[QueryParameter('filter[meta][key]', 'Filter by a metadata key/value pair.', example: 'eu-west')] + #[QueryParameter('filter[tags]', 'Filter by one or more comma-separated tags.', example: 'api,database')] #[QueryParameter('include', 'Include related data (group, incidents, meta).', example: 'meta')] #[QueryParameter('per_page', 'How many items to show per page.', type: 'int', default: 15, example: 20)] #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] @@ -54,6 +56,7 @@ public function index(Request $request) AllowedFilter::exact('status'), AllowedFilter::exact('enabled')->default(true), AllowedFilter::custom('meta', new MetaFilter), + AllowedFilter::custom('tags', new TagsFilter), ]) ->allowedSorts(['name', 'order', 'id']) ->simplePaginate(Number::clamp($request->integer('per_page', 15), min: 1, max: 100)); @@ -75,6 +78,7 @@ protected function allowedIncludes(): array $query->viewableBy($this->isAuthenticated(), $this->tokenCan('incidents.manage')); }), 'meta', + 'tags', ]; } diff --git a/src/Http/Controllers/Api/IncidentController.php b/src/Http/Controllers/Api/IncidentController.php index fc9cc876..37c65c4e 100644 --- a/src/Http/Controllers/Api/IncidentController.php +++ b/src/Http/Controllers/Api/IncidentController.php @@ -10,6 +10,7 @@ use Cachet\Data\Requests\Incident\CreateIncidentRequestData; use Cachet\Data\Requests\Incident\UpdateIncidentRequestData; use Cachet\Filters\MetaFilter; +use Cachet\Filters\TagsFilter; use Cachet\Http\Resources\Incident as IncidentResource; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; @@ -52,6 +53,7 @@ protected function allowedIncludes(): array 'updates', 'user', 'meta', + 'tags', ]; } @@ -59,6 +61,7 @@ protected function allowedIncludes(): array * List Incidents */ #[QueryParameter('filter[meta][key]', 'Filter by a metadata key/value pair.', example: 'eu-west')] + #[QueryParameter('filter[tags]', 'Filter by one or more comma-separated tags.', example: 'api,database')] #[QueryParameter('include', 'Include related data (components, components.group, updates, user, meta).', example: 'meta')] #[QueryParameter('per_page', 'How many items to show per page.', type: 'int', default: 15, example: 20)] #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] @@ -74,6 +77,7 @@ public function index(Request $request) AllowedFilter::scope('occurs_before'), AllowedFilter::scope('occurs_on'), AllowedFilter::custom('meta', new MetaFilter), + AllowedFilter::custom('tags', new TagsFilter), ]) ->allowedSorts(['name', 'status', 'id', 'created_at']) ->defaultSort('-created_at') diff --git a/src/Http/Controllers/Api/MetricController.php b/src/Http/Controllers/Api/MetricController.php index 647de29c..91f68c42 100644 --- a/src/Http/Controllers/Api/MetricController.php +++ b/src/Http/Controllers/Api/MetricController.php @@ -10,6 +10,7 @@ use Cachet\Data\Requests\Metric\CreateMetricRequestData; use Cachet\Data\Requests\Metric\UpdateMetricRequestData; use Cachet\Enums\MetricTypeEnum; +use Cachet\Filters\TagsFilter; use Cachet\Http\Resources\Metric as MetricResource; use Cachet\Models\Metric; use Dedoc\Scramble\Attributes\Group; @@ -19,6 +20,7 @@ use Illuminate\Http\Response; use Illuminate\Routing\Controller; use Illuminate\Support\Number; +use Spatie\QueryBuilder\AllowedFilter; use Spatie\QueryBuilder\AllowedInclude; use Spatie\QueryBuilder\QueryBuilder; @@ -33,6 +35,7 @@ class MetricController extends Controller */ #[QueryParameter('filter[name]', 'Filter by name.', example: 'metric name')] #[QueryParameter('filter[calc_type]', 'Filter by calculation type.', type: MetricTypeEnum::class)] + #[QueryParameter('filter[tags]', 'Filter by one or more comma-separated tags.', example: 'api,database')] #[QueryParameter('per_page', 'How many items to show per page.', type: 'int', default: 15, example: 20)] #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] public function index(Request $request) @@ -46,8 +49,9 @@ public function index(Request $request) $metrics = QueryBuilder::for($query) ->allowedIncludes([ AllowedInclude::relationship('points', 'metricPoints'), + 'tags', ]) - ->allowedFilters(['name', 'calc_type']) + ->allowedFilters(['name', 'calc_type', AllowedFilter::custom('tags', new TagsFilter)]) ->allowedSorts(['name', 'order', 'id']) ->simplePaginate(Number::clamp($request->integer('per_page', 15), min: 1, max: 100)); @@ -74,6 +78,7 @@ public function show(Metric $metric) $metricQuery = QueryBuilder::for(Metric::query()->visible($this->isAuthenticated())) ->allowedIncludes([ AllowedInclude::relationship('points', 'metricPoints'), + 'tags', ]) ->findOrFail($metric->id); diff --git a/src/Http/Controllers/Api/ScheduleController.php b/src/Http/Controllers/Api/ScheduleController.php index 11615266..54d1334d 100644 --- a/src/Http/Controllers/Api/ScheduleController.php +++ b/src/Http/Controllers/Api/ScheduleController.php @@ -12,6 +12,7 @@ use Cachet\Enums\ScheduleStatusEnum; use Cachet\Filters\MetaFilter; use Cachet\Filters\ScheduleStatusFilter; +use Cachet\Filters\TagsFilter; use Cachet\Http\Resources\Schedule as ScheduleResource; use Cachet\Models\Component; use Cachet\Models\ComponentGroup; @@ -54,6 +55,7 @@ protected function allowedIncludes(): array 'updates', 'user', 'meta', + 'tags', ]; } @@ -63,6 +65,7 @@ protected function allowedIncludes(): array #[QueryParameter('filter[name]', 'Filter the resources by name.', example: 'api')] #[QueryParameter('filter[status]', 'Filter the resources by status.', type: ScheduleStatusEnum::class)] #[QueryParameter('filter[meta][key]', 'Filter by a metadata key/value pair.', example: 'eu-west')] + #[QueryParameter('filter[tags]', 'Filter by one or more comma-separated tags.', example: 'api,database')] #[QueryParameter('include', 'Include related data (components, components.group, updates, user, meta).', example: 'meta')] #[QueryParameter('per_page', 'How many items to show per page.', type: 'int', default: 15, example: 20)] #[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)] @@ -75,6 +78,7 @@ public function index(Request $request) 'name', AllowedFilter::custom('status', new ScheduleStatusFilter), AllowedFilter::custom('meta', new MetaFilter), + AllowedFilter::custom('tags', new TagsFilter), ]) ->allowedSorts(['name', 'id', 'scheduled_at', 'completed_at']) ->simplePaginate(Number::clamp($request->integer('per_page', 15), min: 1, max: 100)); diff --git a/src/Http/Resources/Component.php b/src/Http/Resources/Component.php index 48cb1094..c4b3a3c7 100644 --- a/src/Http/Resources/Component.php +++ b/src/Http/Resources/Component.php @@ -53,6 +53,7 @@ public function toRelationships(Request $request): array return [ 'group' => fn () => ComponentGroup::make($this->group), 'incidents' => fn () => Incident::collection($this->incidents), + 'tags' => fn () => Tag::collection($this->tags), ]; } } diff --git a/src/Http/Resources/Incident.php b/src/Http/Resources/Incident.php index 7692fc23..02b108be 100644 --- a/src/Http/Resources/Incident.php +++ b/src/Http/Resources/Incident.php @@ -56,6 +56,7 @@ public function toRelationships(Request $request): array 'components' => fn () => Component::collection($this->components), 'updates' => fn () => Update::collection($this->updates), 'user' => fn () => User::make($this->user), + 'tags' => fn () => Tag::collection($this->tags), ]; } } diff --git a/src/Http/Resources/Metric.php b/src/Http/Resources/Metric.php index 785a16d0..8ddadbda 100644 --- a/src/Http/Resources/Metric.php +++ b/src/Http/Resources/Metric.php @@ -38,6 +38,7 @@ public function toRelationships(Request $request): array { return [ 'points' => fn () => MetricPoint::collection($this->metricPoints), + 'tags' => fn () => Tag::collection($this->tags), ]; } } diff --git a/src/Http/Resources/Schedule.php b/src/Http/Resources/Schedule.php index 3e915b09..cd4e9acb 100644 --- a/src/Http/Resources/Schedule.php +++ b/src/Http/Resources/Schedule.php @@ -50,6 +50,7 @@ public function toRelationships(Request $request): array return [ 'components' => fn () => Component::collection($this->components), 'updates' => fn () => Update::collection($this->updates), + 'tags' => fn () => Tag::collection($this->tags), ]; } } diff --git a/src/Http/Resources/Tag.php b/src/Http/Resources/Tag.php index 8b3ec05c..4544096d 100644 --- a/src/Http/Resources/Tag.php +++ b/src/Http/Resources/Tag.php @@ -5,10 +5,11 @@ use Illuminate\Http\Request; use TiMacDonald\JsonApi\JsonApiResource; +/** @mixin \Cachet\Models\Tag */ class Tag extends JsonApiResource { public function toAttributes(Request $request): array { - return parent::toAttributes($request); + return ['id' => $this->id, 'name' => $this->name, 'slug' => $this->slug]; } } diff --git a/src/Mcp/Concerns/PresentsResources.php b/src/Mcp/Concerns/PresentsResources.php index 30870203..c881f5b2 100644 --- a/src/Mcp/Concerns/PresentsResources.php +++ b/src/Mcp/Concerns/PresentsResources.php @@ -46,6 +46,7 @@ protected function presentComponent(Component $component): array 'order' => $component->order, 'enabled' => $component->enabled, 'component_group_id' => $component->component_group_id, + 'tags' => $component->tags->pluck('name')->all(), 'created_at' => $component->created_at?->toIso8601String(), 'updated_at' => $component->updated_at?->toIso8601String(), ]; @@ -84,6 +85,7 @@ protected function presentIncident(Incident $incident): array 'message' => $incident->message, 'visible' => $this->presentEnum($incident->visible), 'stickied' => $incident->stickied, + 'tags' => $incident->tags->pluck('name')->all(), 'occurred_at' => $incident->occurred_at?->toIso8601String(), 'published_at' => $incident->published_at?->toIso8601String(), 'created_at' => $incident->created_at?->toIso8601String(), @@ -139,6 +141,7 @@ protected function presentSchedule(Schedule $schedule): array 'scheduled_at' => $schedule->scheduled_at?->toIso8601String(), 'completed_at' => $schedule->completed_at?->toIso8601String(), 'published_at' => $schedule->published_at?->toIso8601String(), + 'tags' => $schedule->tags->pluck('name')->all(), 'created_at' => $schedule->created_at?->toIso8601String(), 'updated_at' => $schedule->updated_at?->toIso8601String(), ], $schedule->relationLoaded('components') ? [ @@ -165,6 +168,7 @@ protected function presentMetric(Metric $metric): array 'threshold' => $metric->threshold, 'visible' => $this->presentEnum($metric->visible), 'order' => $metric->order, + 'tags' => $metric->tags->pluck('name')->all(), 'created_at' => $metric->created_at?->toIso8601String(), 'updated_at' => $metric->updated_at?->toIso8601String(), ], $metric->relationLoaded('metricPoints') ? [ diff --git a/src/Mcp/Tools/Components/CreateComponent.php b/src/Mcp/Tools/Components/CreateComponent.php index 40d7da0b..30352b97 100644 --- a/src/Mcp/Tools/Components/CreateComponent.php +++ b/src/Mcp/Tools/Components/CreateComponent.php @@ -37,6 +37,7 @@ public function schema(JsonSchema $schema): array 'order' => $schema->integer()->min(0)->description('The display order of the component.'), 'enabled' => $schema->boolean()->default(true), 'component_group_id' => $schema->integer()->description('The ID of the component group this component belongs to.'), + 'tags' => $schema->array()->items($schema->string())->description('Tags to apply to the component.'), ]; } diff --git a/src/Mcp/Tools/Components/ListComponents.php b/src/Mcp/Tools/Components/ListComponents.php index a5ad341b..e1601175 100644 --- a/src/Mcp/Tools/Components/ListComponents.php +++ b/src/Mcp/Tools/Components/ListComponents.php @@ -37,6 +37,7 @@ public function schema(JsonSchema $schema): array ->description('Filter by status: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), 'enabled' => $schema->boolean()->default(true)->description('Filter by enabled state. Disabled components are hidden by default and are only visible to authenticated callers.'), 'component_group_id' => $schema->integer()->description('Filter by component group ID.'), + 'tags' => $schema->array()->items($schema->string())->description('Return components with any of these tags.'), 'per_page' => $schema->integer()->min(1)->max(100)->default(15), 'page' => $schema->integer()->min(1)->default(1), ]; @@ -49,6 +50,7 @@ public function handle(Request $request): ResponseFactory ->when($request->filled('status'), fn ($query) => $query->where('status', $request->integer('status'))) ->where('enabled', $request->boolean('enabled', true)) ->when($request->filled('component_group_id'), fn ($query) => $query->where('component_group_id', $request->integer('component_group_id'))) + ->when($request->filled('tags'), fn ($query) => $query->withAnyTags($request->array('tags'))) ->orderBy('order') ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); diff --git a/src/Mcp/Tools/Components/UpdateComponent.php b/src/Mcp/Tools/Components/UpdateComponent.php index 8b5502fa..a4c6a993 100644 --- a/src/Mcp/Tools/Components/UpdateComponent.php +++ b/src/Mcp/Tools/Components/UpdateComponent.php @@ -33,6 +33,7 @@ public function schema(JsonSchema $schema): array { return [ 'id' => $schema->integer()->required()->description('The component ID.'), + 'tags' => $schema->array()->items($schema->string())->description('Replace the component tags.'), 'name' => $schema->string()->max(255)->description('The name of the component.'), 'description' => $schema->string()->description('A description of the component.'), 'status' => $schema->integer() diff --git a/src/Mcp/Tools/Incidents/CreateIncident.php b/src/Mcp/Tools/Incidents/CreateIncident.php index 1d284752..b2f3b684 100644 --- a/src/Mcp/Tools/Incidents/CreateIncident.php +++ b/src/Mcp/Tools/Incidents/CreateIncident.php @@ -51,6 +51,7 @@ public function schema(JsonSchema $schema): array ->description('The status to display for the component while the incident is unresolved: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance. The component\'s own status is left unchanged; the overlay is reflected in its latest_status and reverts when the incident is fixed. Use update_component to change a component\'s own status.'), ])) ->description('Affected components and the status to display for each while the incident is unresolved.'), + 'tags' => $schema->array()->items($schema->string())->description('Tags to apply to the incident.'), ]; } diff --git a/src/Mcp/Tools/Incidents/ListIncidents.php b/src/Mcp/Tools/Incidents/ListIncidents.php index a0c540b6..6c56ba36 100644 --- a/src/Mcp/Tools/Incidents/ListIncidents.php +++ b/src/Mcp/Tools/Incidents/ListIncidents.php @@ -37,6 +37,7 @@ public function schema(JsonSchema $schema): array 'status' => $schema->integer() ->enum(array_column(IncidentStatusEnum::cases(), 'value')) ->description('Filter by status: 0 unknown, 1 investigating, 2 identified, 3 watching, 4 fixed.'), + 'tags' => $schema->array()->items($schema->string())->description('Return incidents with any of these tags.'), 'per_page' => $schema->integer()->min(1)->max(100)->default(15), 'page' => $schema->integer()->min(1)->default(1), ]; @@ -48,6 +49,7 @@ public function handle(Request $request): ResponseFactory ->viewableBy($this->isAuthenticated(), $this->tokenCan('incidents.manage')) ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) ->when($request->filled('status'), fn ($query) => $query->where('status', $request->integer('status'))) + ->when($request->filled('tags'), fn ($query) => $query->withAnyTags($request->array('tags'))) ->latest() ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); diff --git a/src/Mcp/Tools/Incidents/UpdateIncident.php b/src/Mcp/Tools/Incidents/UpdateIncident.php index 44828bdb..13d2e95c 100644 --- a/src/Mcp/Tools/Incidents/UpdateIncident.php +++ b/src/Mcp/Tools/Incidents/UpdateIncident.php @@ -32,6 +32,7 @@ public function schema(JsonSchema $schema): array { return [ 'id' => $schema->integer()->required()->description('The incident ID.'), + 'tags' => $schema->array()->items($schema->string())->description('Replace the incident tags.'), 'name' => $schema->string()->max(255)->description('The name of the incident.'), 'message' => $schema->string()->description('The incident message, in Markdown.'), 'status' => $schema->integer() diff --git a/src/Mcp/Tools/Metrics/CreateMetric.php b/src/Mcp/Tools/Metrics/CreateMetric.php index 70726b6b..423fa6b2 100644 --- a/src/Mcp/Tools/Metrics/CreateMetric.php +++ b/src/Mcp/Tools/Metrics/CreateMetric.php @@ -38,6 +38,7 @@ public function schema(JsonSchema $schema): array 'display_chart' => $schema->boolean()->description('Whether to render a chart for the metric on the status page.'), 'threshold' => $schema->integer()->min(0)->max(60)->description('The number of minutes between plotted points. Must be a factor of sixty.'), 'places' => $schema->integer()->description('The number of decimal places shown for values.'), + 'tags' => $schema->array()->items($schema->string())->description('Tags to apply to the metric.'), ]; } diff --git a/src/Mcp/Tools/Metrics/ListMetrics.php b/src/Mcp/Tools/Metrics/ListMetrics.php index 1508878c..28fbecbb 100644 --- a/src/Mcp/Tools/Metrics/ListMetrics.php +++ b/src/Mcp/Tools/Metrics/ListMetrics.php @@ -31,6 +31,7 @@ public function schema(JsonSchema $schema): array { return [ 'name' => $schema->string()->description('Filter by partial metric name.'), + 'tags' => $schema->array()->items($schema->string())->description('Return metrics with any of these tags.'), 'per_page' => $schema->integer()->min(1)->max(100)->default(15), 'page' => $schema->integer()->min(1)->default(1), ]; @@ -41,6 +42,7 @@ public function handle(Request $request): ResponseFactory $metrics = Metric::query() ->visible($this->isAuthenticated()) ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) + ->when($request->filled('tags'), fn ($query) => $query->withAnyTags($request->array('tags'))) ->orderBy('order') ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); diff --git a/src/Mcp/Tools/Metrics/UpdateMetric.php b/src/Mcp/Tools/Metrics/UpdateMetric.php index eb6cf1ab..e9396602 100644 --- a/src/Mcp/Tools/Metrics/UpdateMetric.php +++ b/src/Mcp/Tools/Metrics/UpdateMetric.php @@ -31,6 +31,7 @@ public function schema(JsonSchema $schema): array { return [ 'id' => $schema->integer()->required()->description('The metric ID.'), + 'tags' => $schema->array()->items($schema->string())->description('Replace the metric tags.'), 'name' => $schema->string()->max(255)->description('The name of the metric.'), 'suffix' => $schema->string()->max(255)->description('The suffix shown after the metric value, such as ms or %.'), 'description' => $schema->string()->description('A description of the metric.'), diff --git a/src/Mcp/Tools/Schedules/CreateSchedule.php b/src/Mcp/Tools/Schedules/CreateSchedule.php index 2c77ee6b..9b06365e 100644 --- a/src/Mcp/Tools/Schedules/CreateSchedule.php +++ b/src/Mcp/Tools/Schedules/CreateSchedule.php @@ -43,6 +43,7 @@ public function schema(JsonSchema $schema): array ->description('The status to show for the component during the window: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), ])) ->description('Affected components and the status to show for each.'), + 'tags' => $schema->array()->items($schema->string())->description('Tags to apply to the maintenance schedule.'), ]; } diff --git a/src/Mcp/Tools/Schedules/ListSchedules.php b/src/Mcp/Tools/Schedules/ListSchedules.php index f4960224..4e318bcb 100644 --- a/src/Mcp/Tools/Schedules/ListSchedules.php +++ b/src/Mcp/Tools/Schedules/ListSchedules.php @@ -29,6 +29,7 @@ public function schema(JsonSchema $schema): array { return [ 'name' => $schema->string()->description('Filter by partial schedule name.'), + 'tags' => $schema->array()->items($schema->string())->description('Return schedules with any of these tags.'), 'per_page' => $schema->integer()->min(1)->max(100)->default(15), 'page' => $schema->integer()->min(1)->default(1), ]; @@ -38,6 +39,7 @@ public function handle(Request $request): ResponseFactory { $schedules = Schedule::query() ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) + ->when($request->filled('tags'), fn ($query) => $query->withAnyTags($request->array('tags'))) ->orderByDesc('scheduled_at') ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); diff --git a/src/Mcp/Tools/Schedules/UpdateSchedule.php b/src/Mcp/Tools/Schedules/UpdateSchedule.php index 84f03c4c..192eb855 100644 --- a/src/Mcp/Tools/Schedules/UpdateSchedule.php +++ b/src/Mcp/Tools/Schedules/UpdateSchedule.php @@ -32,6 +32,7 @@ public function schema(JsonSchema $schema): array { return [ 'id' => $schema->integer()->required()->description('The schedule ID.'), + 'tags' => $schema->array()->items($schema->string())->description('Replace the maintenance schedule tags.'), 'name' => $schema->string()->max(255)->description('The name of the maintenance schedule.'), 'message' => $schema->string()->description('The schedule message, in Markdown.'), 'scheduled_at' => $schema->string()->description('When the maintenance starts, as an ISO-8601 or Y-m-d H:i:s datetime.'), diff --git a/src/Models/Component.php b/src/Models/Component.php index 697f66cc..c57d4d64 100644 --- a/src/Models/Component.php +++ b/src/Models/Component.php @@ -4,6 +4,7 @@ use Cachet\Cachet; use Cachet\Concerns\HasMeta; +use Cachet\Concerns\HasTags; use Cachet\Concerns\Metable; use Cachet\Database\Factories\ComponentFactory; use Cachet\Enums\ComponentStatusEnum; @@ -61,6 +62,7 @@ class Component extends Model implements Metable use HasFactory; use HasMeta; + use HasTags; use SoftDeletes; /** @var array */ diff --git a/src/Models/Incident.php b/src/Models/Incident.php index 4c0ff9bb..af8f821e 100644 --- a/src/Models/Incident.php +++ b/src/Models/Incident.php @@ -4,6 +4,7 @@ use Cachet\Cachet; use Cachet\Concerns\HasMeta; +use Cachet\Concerns\HasTags; use Cachet\Concerns\HasVisibility; use Cachet\Concerns\Metable; use Cachet\Concerns\Publishable; @@ -73,6 +74,7 @@ class Incident extends Model implements Metable use HasFactory; use HasMeta; + use HasTags; use HasVisibility; use Publishable; use SoftDeletes; diff --git a/src/Models/Metric.php b/src/Models/Metric.php index 43d5e3ac..6cc9ad78 100644 --- a/src/Models/Metric.php +++ b/src/Models/Metric.php @@ -2,6 +2,7 @@ namespace Cachet\Models; +use Cachet\Concerns\HasTags; use Cachet\Concerns\HasVisibility; use Cachet\Database\Factories\MetricFactory; use Cachet\Enums\MetricTypeEnum; @@ -42,6 +43,7 @@ class Metric extends Model /** @use HasFactory */ use HasFactory; + use HasTags; use HasVisibility; /** @var array */ diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index fec51fb3..cc8a5a8f 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -6,6 +6,7 @@ use Cachet\Actions\Schedule\NotifyScheduleRescheduledSubscribers; use Cachet\Cachet; use Cachet\Concerns\HasMeta; +use Cachet\Concerns\HasTags; use Cachet\Concerns\Metable; use Cachet\Concerns\Publishable; use Cachet\Database\Factories\ScheduleFactory; @@ -55,6 +56,7 @@ class Schedule extends Model implements Metable use HasFactory; use HasMeta; + use HasTags; use Publishable; use SoftDeletes; diff --git a/src/Models/Tag.php b/src/Models/Tag.php new file mode 100644 index 00000000..294182f1 --- /dev/null +++ b/src/Models/Tag.php @@ -0,0 +1,22 @@ + */ + protected $fillable = ['name', 'slug']; + + protected static function booted(): void + { + static::saving(function (self $tag): void { + $tag->slug = Str::slug($tag->name); + }); + } +} diff --git a/src/Settings/AppSettings.php b/src/Settings/AppSettings.php index b2a5ba85..581d5e0c 100644 --- a/src/Settings/AppSettings.php +++ b/src/Settings/AppSettings.php @@ -54,6 +54,8 @@ class AppSettings extends Settings public bool $show_component_group_status = true; + public bool $show_component_tags = false; + public static function group(): string { return 'app'; diff --git a/src/View/Components/ComponentGroups.php b/src/View/Components/ComponentGroups.php index 47920d8f..3b243414 100644 --- a/src/View/Components/ComponentGroups.php +++ b/src/View/Components/ComponentGroups.php @@ -20,7 +20,7 @@ public function render(): View|Closure|string ->enabled() ->whereNull('component_group_id') ->orderBy('order') - ->with(['unresolvedIncidents', 'activeMaintenance']) + ->with(['unresolvedIncidents', 'activeMaintenance', 'tags']) ->withCount(['incidents' => fn ($query) => $query->unresolved()->viewableBy(false)]) ->get(), ]); @@ -36,6 +36,7 @@ private function componentGroups(): Collection 'components' => fn ($query) => $query->enabled()->orderBy('order')->withCount(['incidents' => fn ($query) => $query->unresolved()->viewableBy(false)]), 'components.unresolvedIncidents', 'components.activeMaintenance', + 'components.tags', ]) ->visible(auth()->check()) ->orderBy('order') diff --git a/tests/Feature/StatusPage/StatusPageTest.php b/tests/Feature/StatusPage/StatusPageTest.php index 1afb1bfc..1603fbfe 100644 --- a/tests/Feature/StatusPage/StatusPageTest.php +++ b/tests/Feature/StatusPage/StatusPageTest.php @@ -86,6 +86,17 @@ ->not->toMatch('/Core services\s*<\\/h2>\s*]*>\s*Major outage\s*<\\/span>/'); }); +it('can display component tags', function () { + $component = Component::factory()->create(); + $component->syncTags(['API']); + + $settings = app(AppSettings::class); + $settings->show_component_tags = true; + $settings->save(); + + $this->get(route('cachet.status-page'))->assertSee('API'); +}); + it('renders the status page in the configured locale', function () { $settings = app(AppSettings::class); $settings->locale = 'de'; diff --git a/tests/Unit/Models/TagsTest.php b/tests/Unit/Models/TagsTest.php new file mode 100644 index 00000000..304f074f --- /dev/null +++ b/tests/Unit/Models/TagsTest.php @@ -0,0 +1,54 @@ +create(), + Incident::factory()->create(), + Metric::factory()->create(), + Schedule::factory()->create(), + ]; + + foreach ($models as $model) { + $model->syncTags(['API', 'Database', 'api']); + + expect($model->fresh()->tags->pluck('name')->all())->toBe(['API', 'Database']); + } +}); + +it('filters resources by any supplied tag', function (): void { + $api = Component::factory()->create(); + $api->syncTags(['API']); + + $database = Component::factory()->create(); + $database->syncTags(['Database']); + + expect(Component::query()->withAnyTags(['api', 'database'])->pluck('id')->all()) + ->toContain($api->id, $database->id); +}); + +it('filters components by tags through the API', function (): void { + $api = Component::factory()->create(['name' => 'Public API']); + $api->syncTags(['API']); + + $database = Component::factory()->create(['name' => 'Database']); + $database->syncTags(['Database']); + + $this->getJson('/status/api/components?filter[tags]=api,database') + ->assertOk() + ->assertJsonCount(2, 'data'); +}); + +it('regenerates a tag slug when its name changes', function (): void { + $component = Component::factory()->create(); + $component->syncTags(['Public API']); + + $tag = $component->tags->first(); + $tag->update(['name' => 'Developer API']); + + expect($tag->refresh()->slug)->toBe('developer-api'); +});