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');
+});