diff --git a/app/Livewire/Servers/Groups.php b/app/Livewire/Servers/Groups.php
new file mode 100644
index 0000000..6f846e0
--- /dev/null
+++ b/app/Livewire/Servers/Groups.php
@@ -0,0 +1,159 @@
+can('manage-fleet'), 403);
+ }
+
+ public function title(): string
+ {
+ return __('groups.title');
+ }
+
+ private function gate(): void
+ {
+ abort_unless(Auth::user()?->can('manage-fleet'), 403);
+ }
+
+ private function audit(string $action, string $target): void
+ {
+ AuditEvent::create([
+ 'user_id' => Auth::id(),
+ 'actor' => Auth::user()?->name ?? 'system',
+ 'action' => $action,
+ 'target' => $target,
+ 'ip' => request()->ip(),
+ ]);
+ }
+
+ public function create(): void
+ {
+ $this->gate();
+
+ $data = $this->validate([
+ 'name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')],
+ 'color' => ['required', Rule::in(ServerGroup::COLORS)],
+ ]);
+
+ $group = ServerGroup::create($data);
+ $this->audit('group.create', $group->name);
+ $this->reset('name');
+ $this->color = 'accent';
+ $this->dispatch('notify', message: __('groups.created', ['name' => $group->name]));
+ }
+
+ public function rename(string $uuid, string $name): void
+ {
+ $this->gate();
+ $group = ServerGroup::where('uuid', $uuid)->firstOrFail();
+
+ $name = trim($name);
+ $validated = validator(
+ ['name' => $name],
+ ['name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')->ignore($group->id)]],
+ )->validate();
+
+ $group->update(['name' => $validated['name']]);
+ $this->audit('group.update', $group->name);
+ }
+
+ public function setColor(string $uuid, string $color): void
+ {
+ $this->gate();
+ if (! in_array($color, ServerGroup::COLORS, true)) {
+ return; // saving() would coerce it anyway; refuse the bad token outright
+ }
+ $group = ServerGroup::where('uuid', $uuid)->firstOrFail();
+ $group->update(['color' => $color]);
+ $this->audit('group.update', $group->name);
+ }
+
+ /** Add or remove a server from a group (checkbox toggle in the assignment grid). */
+ public function toggleMember(string $groupUuid, string $serverUuid): void
+ {
+ $this->gate();
+ $group = ServerGroup::where('uuid', $groupUuid)->firstOrFail();
+ $server = Server::where('uuid', $serverUuid)->firstOrFail();
+
+ $group->servers()->toggle($server->id);
+ $this->audit('group.members', $group->name);
+ }
+
+ public function confirmDelete(string $uuid): void
+ {
+ $this->gate();
+ $group = ServerGroup::where('uuid', $uuid)->firstOrFail();
+
+ $this->dispatch('openModal',
+ component: 'modals.confirm-action',
+ arguments: [
+ 'heading' => __('groups.delete_heading'),
+ 'body' => __('groups.delete_body', ['name' => $group->name]),
+ 'confirmLabel' => __('common.delete'),
+ 'danger' => true,
+ 'icon' => 'trash',
+ 'notify' => __('groups.deleted', ['name' => $group->name]),
+ // The confirm modal writes the group.delete AuditEvent from this sealed descriptor,
+ // so deleteGroup() must NOT audit again (avoids a double row).
+ 'token' => ConfirmToken::issue('groupConfirmed', ['uuid' => $group->uuid], 'group.delete', $group->name, null),
+ ],
+ );
+ }
+
+ #[On('groupConfirmed')]
+ public function deleteGroup(string $confirmToken): void
+ {
+ $this->gate();
+
+ try {
+ $payload = ConfirmToken::consume($confirmToken, 'groupConfirmed');
+ } catch (InvalidConfirmToken) {
+ return; // forged / replayed / direct-bypass attempt — no-op
+ }
+
+ $group = ServerGroup::where('uuid', $payload['params']['uuid'])->first();
+ if (! $group) {
+ return;
+ }
+
+ // NOT audited here — the confirm modal already wrote group.delete from the sealed token.
+ $group->delete(); // pivot rows cascade; servers are untouched
+ }
+
+ public function render(): View
+ {
+ return view('livewire.servers.groups', [
+ 'groups' => ServerGroup::withCount('servers')->with('servers:id,uuid')->orderBy('name')->get(),
+ 'servers' => Server::orderBy('name')->get(['id', 'uuid', 'name']),
+ 'colors' => ServerGroup::COLORS,
+ ])->title($this->title());
+ }
+}
diff --git a/app/Livewire/Servers/Index.php b/app/Livewire/Servers/Index.php
index 4eaa98c..e3f7cb3 100644
--- a/app/Livewire/Servers/Index.php
+++ b/app/Livewire/Servers/Index.php
@@ -3,9 +3,11 @@
namespace App\Livewire\Servers;
use App\Models\Server;
+use App\Models\ServerGroup;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
+use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
@@ -14,6 +16,10 @@ class Index extends Component
/** Live search over name / IP. */
public string $search = '';
+ /** Optional group filter (a ServerGroup uuid, shareable via the query string). */
+ #[Url]
+ public ?string $group = null;
+
/** Page
; localized at runtime (attributes can't call __()). */
public function title(): string
{
@@ -31,7 +37,11 @@ class Index extends Component
{
$term = trim($this->search);
+ // Resolve the group filter; an unknown/removed uuid falls back to "all" (never errors).
+ $activeGroup = $this->group ? ServerGroup::where('uuid', $this->group)->first() : null;
+
$servers = Server::orderBy('name')
+ ->when($activeGroup, fn ($query) => $query->inGroup($activeGroup->id))
->when($term !== '', function ($query) use ($term) {
$query->where(function ($q) use ($term) {
$q->where('name', 'like', "%{$term}%")
@@ -48,6 +58,8 @@ class Index extends Component
return view('livewire.servers.index', [
'servers' => $servers,
+ 'groups' => ServerGroup::withCount('servers')->orderBy('name')->get(),
+ 'activeGroup' => $activeGroup,
'total' => (int) $counts->sum(),
'online' => (int) ($counts['online'] ?? 0),
'warning' => (int) ($counts['warning'] ?? 0),
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 21965d1..fe4e1d5 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str;
@@ -32,6 +33,17 @@ class Server extends Model
return $this->hasOne(SshCredential::class);
}
+ public function groups(): BelongsToMany
+ {
+ return $this->belongsToMany(ServerGroup::class);
+ }
+
+ /** Servers that belong to the given group (by group id). */
+ public function scopeInGroup(Builder $query, int $groupId): void
+ {
+ $query->whereHas('groups', fn (Builder $q) => $q->where('server_groups.id', $groupId));
+ }
+
/**
* Servers whose stored SSH credential is present AND not disabled — the live poll set.
* A disabled ("gesperrt") credential is refused by CredentialVault::resolve() at connect
diff --git a/app/Models/ServerGroup.php b/app/Models/ServerGroup.php
new file mode 100644
index 0000000..a466e3a
--- /dev/null
+++ b/app/Models/ServerGroup.php
@@ -0,0 +1,38 @@
+ $g->uuid ??= (string) Str::uuid());
+ // Fall back to the brand colour if an out-of-allow-list token slips in (defence in depth;
+ // the component validates too — this guarantees the token is always a real @theme colour).
+ static::saving(function (self $g): void {
+ if (! in_array($g->color, self::COLORS, true)) {
+ $g->color = 'accent';
+ }
+ });
+ }
+
+ // R11: addressed by uuid in URLs, never the bigint id.
+ public function getRouteKeyName(): string
+ {
+ return 'uuid';
+ }
+
+ public function servers(): BelongsToMany
+ {
+ return $this->belongsToMany(Server::class);
+ }
+}
diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php
index 06852c5..c585a54 100644
--- a/app/Support/Confirm/ConfirmToken.php
+++ b/app/Support/Confirm/ConfirmToken.php
@@ -66,6 +66,7 @@ class ConfirmToken
'releaseStaged',
'releasePublic',
'releaseYank',
+ 'groupConfirmed',
];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
diff --git a/database/migrations/2026_07_05_100001_create_server_groups_table.php b/database/migrations/2026_07_05_100001_create_server_groups_table.php
new file mode 100644
index 0000000..766c536
--- /dev/null
+++ b/database/migrations/2026_07_05_100001_create_server_groups_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->uuid('uuid')->unique();
+ $table->string('name')->unique();
+ $table->string('color')->default('accent'); // a @theme token key (validated in the model)
+ $table->timestamps();
+ });
+
+ Schema::create('server_server_group', function (Blueprint $table) {
+ $table->foreignId('server_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('server_group_id')->constrained()->cascadeOnDelete();
+ $table->primary(['server_id', 'server_group_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('server_server_group');
+ Schema::dropIfExists('server_groups');
+ }
+};
diff --git a/lang/de/audit.php b/lang/de/audit.php
index 39366d0..4d86fd5 100644
--- a/lang/de/audit.php
+++ b/lang/de/audit.php
@@ -56,6 +56,10 @@ return [
'file.edit' => 'Datei bearbeitet',
'firewall.rule_add' => 'Firewall-Regel hinzugefügt',
'firewall.rule_delete' => 'Firewall-Regel gelöscht',
+ 'group.create' => 'Server-Gruppe angelegt',
+ 'group.update' => 'Server-Gruppe geändert',
+ 'group.delete' => 'Server-Gruppe gelöscht',
+ 'group.members' => 'Gruppen-Zuordnung geändert',
'mail.configure' => 'E-Mail (SMTP) konfiguriert',
'password.change' => 'Passwort geändert',
'password.reset' => 'Passwort zurückgesetzt',
diff --git a/lang/de/groups.php b/lang/de/groups.php
new file mode 100644
index 0000000..6d4636b
--- /dev/null
+++ b/lang/de/groups.php
@@ -0,0 +1,34 @@
+ 'Server-Gruppen',
+ 'subtitle' => 'Flotte nach Umgebung oder Rolle organisieren',
+ 'eyebrow' => 'Flotte',
+ 'back_to_servers' => 'Zurück zu Servern',
+
+ // New-group form
+ 'new_group' => 'Neue Gruppe',
+ 'name_label' => 'Name',
+ 'name_placeholder' => 'Produktion, Web, Kunde A …',
+ 'color_label' => 'Farbe',
+ 'create' => 'Anlegen',
+
+ // List
+ 'members_count' => '{0}keine Server|{1}:count Server|[2,*]:count Server',
+ 'assign' => 'Server zuweisen',
+ 'no_servers' => 'Noch keine Server in der Flotte.',
+ 'empty_title' => 'Keine Gruppen',
+ 'empty' => 'Noch keine Gruppen angelegt. Lege oben die erste an.',
+
+ // Filter (servers index)
+ 'filter_all' => 'Alle',
+ 'filter_label' => 'Nach Gruppe filtern',
+
+ // Toasts
+ 'created' => 'Gruppe „:name“ angelegt.',
+ 'deleted' => 'Gruppe „:name“ gelöscht.',
+
+ // Delete confirm
+ 'delete_heading' => 'Gruppe löschen',
+ 'delete_body' => 'Die Gruppe „:name“ wird gelöscht. Die Server bleiben bestehen, nur die Zuordnung entfällt.',
+];
diff --git a/lang/en/audit.php b/lang/en/audit.php
index f78aae1..65ae915 100644
--- a/lang/en/audit.php
+++ b/lang/en/audit.php
@@ -56,6 +56,10 @@ return [
'file.edit' => 'File edited',
'firewall.rule_add' => 'Firewall rule added',
'firewall.rule_delete' => 'Firewall rule deleted',
+ 'group.create' => 'Server group created',
+ 'group.update' => 'Server group changed',
+ 'group.delete' => 'Server group deleted',
+ 'group.members' => 'Group membership changed',
'mail.configure' => 'E-mail (SMTP) configured',
'password.change' => 'Password changed',
'password.reset' => 'Password reset',
diff --git a/lang/en/groups.php b/lang/en/groups.php
new file mode 100644
index 0000000..5c3ef2e
--- /dev/null
+++ b/lang/en/groups.php
@@ -0,0 +1,34 @@
+ 'Server groups',
+ 'subtitle' => 'Organise the fleet by environment or role',
+ 'eyebrow' => 'Fleet',
+ 'back_to_servers' => 'Back to servers',
+
+ // New-group form
+ 'new_group' => 'New group',
+ 'name_label' => 'Name',
+ 'name_placeholder' => 'Production, Web, Customer A …',
+ 'color_label' => 'Colour',
+ 'create' => 'Create',
+
+ // List
+ 'members_count' => '{0}no servers|{1}:count server|[2,*]:count servers',
+ 'assign' => 'Assign servers',
+ 'no_servers' => 'No servers in the fleet yet.',
+ 'empty_title' => 'No groups',
+ 'empty' => 'No groups yet. Create the first one above.',
+
+ // Filter (servers index)
+ 'filter_all' => 'All',
+ 'filter_label' => 'Filter by group',
+
+ // Toasts
+ 'created' => 'Group “:name” created.',
+ 'deleted' => 'Group “:name” deleted.',
+
+ // Delete confirm
+ 'delete_heading' => 'Delete group',
+ 'delete_body' => 'The group “:name” will be deleted. The servers remain; only the grouping is removed.',
+];
diff --git a/resources/views/livewire/servers/groups.blade.php b/resources/views/livewire/servers/groups.blade.php
new file mode 100644
index 0000000..4754f31
--- /dev/null
+++ b/resources/views/livewire/servers/groups.blade.php
@@ -0,0 +1,107 @@
+@php
+ // Colour token key -> literal @theme bg utility (literal strings so Tailwind's source scan
+ // generates them; a dynamic bg-{{ $var }} would never be emitted). R3: tokens only, no hex.
+ $dot = [
+ 'accent' => 'bg-accent', 'cyan' => 'bg-cyan', 'online' => 'bg-online',
+ 'warning' => 'bg-warning', 'offline' => 'bg-offline', 'ink' => 'bg-ink-3',
+ ];
+@endphp
+
+
+ {{-- Header --}}
+
+
+ {{-- New group --}}
+
+
+
+
+ {{-- Groups list --}}
+ @if ($groups->isEmpty())
+
+
+
+
{{ __('groups.empty_title') }}
+
{{ __('groups.empty') }}
+
+
+ @else
+
+ @foreach ($groups as $g)
+
+
+
+
+
{{ trans_choice('groups.members_count', $g->servers_count, ['count' => $g->servers_count]) }}
+
+ {{-- Recolour --}}
+
+ @foreach ($colors as $c)
+
+ @endforeach
+
+
+
{{ __('common.delete') }}
+
+
+ {{-- Membership grid --}}
+
+
{{ __('groups.assign') }}
+ @if ($servers->isEmpty())
+
{{ __('groups.no_servers') }}
+ @else
+
+ @php($memberIds = $g->servers->pluck('id'))
+ @foreach ($servers as $s)
+
+ @endforeach
+
+ @endif
+
+
+ @endforeach
+
+ @endif
+
diff --git a/resources/views/livewire/servers/index.blade.php b/resources/views/livewire/servers/index.blade.php
index 8ff979d..abd60a9 100644
--- a/resources/views/livewire/servers/index.blade.php
+++ b/resources/views/livewire/servers/index.blade.php
@@ -1,5 +1,7 @@
@php
$label = ['online' => __('servers.status_online'), 'warning' => __('servers.status_warning'), 'offline' => __('servers.status_offline'), 'pending' => __('servers.status_pending')];
+ // Group colour token -> literal bg utility (see servers/groups.blade.php); R3 tokens only.
+ $dot = ['accent' => 'bg-accent', 'cyan' => 'bg-cyan', 'online' => 'bg-online', 'warning' => 'bg-warning', 'offline' => 'bg-offline', 'ink' => 'bg-ink-3'];
@endphp
@@ -11,6 +13,10 @@
+ {{-- Group filter (only when groups exist) --}}
+ @if ($groups->isNotEmpty())
+
+
+ @foreach ($groups as $g)
+
+ @endforeach
+
+ @endif
+
{{-- KPI grid: 1 → 2 → 4 --}}
diff --git a/routes/web.php b/routes/web.php
index 4fc032f..7509159 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -208,6 +208,8 @@ Route::middleware('auth')->group(function () {
Route::get('/', Dashboard::class)->name('dashboard');
Route::get('/servers', Servers\Index::class)->name('servers.index');
+ // MUST be registered before /servers/{server}, or "groups" is captured as a server uuid.
+ Route::get('/servers/groups', Servers\Groups::class)->middleware('can:manage-fleet')->name('servers.groups');
Route::get('/servers/{server}', Servers\Show::class)->name('servers.show');
// History-graph data for the Server-Details chart (the Alpine island fetches this per range).
diff --git a/tests/Feature/ServerGroupModelTest.php b/tests/Feature/ServerGroupModelTest.php
new file mode 100644
index 0000000..5dcd896
--- /dev/null
+++ b/tests/Feature/ServerGroupModelTest.php
@@ -0,0 +1,73 @@
+ 'Produktion', 'color' => 'accent']);
+
+ $this->assertNotEmpty($g->uuid);
+ $this->assertSame('uuid', $g->getRouteKeyName());
+ }
+
+ public function test_color_falls_back_to_accent_for_an_unknown_token(): void
+ {
+ // R3: the colour must always be a real @theme token key — never arbitrary/raw text.
+ $g = ServerGroup::create(['name' => 'x', 'color' => 'rebeccapurple']);
+
+ $this->assertSame('accent', $g->fresh()->color);
+ }
+
+ public function test_a_known_color_token_is_kept(): void
+ {
+ $g = ServerGroup::create(['name' => 'y', 'color' => 'cyan']);
+
+ $this->assertSame('cyan', $g->fresh()->color);
+ }
+
+ public function test_servers_belong_to_groups_both_ways(): void
+ {
+ $prod = ServerGroup::create(['name' => 'prod', 'color' => 'online']);
+ $a = Server::create(['name' => 'a', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
+ $b = Server::create(['name' => 'b', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
+
+ $prod->servers()->attach([$a->id, $b->id]);
+
+ $this->assertEqualsCanonicalizing([$a->id, $b->id], $prod->servers->pluck('id')->all());
+ $this->assertTrue($a->groups->contains($prod));
+ }
+
+ public function test_in_group_scope_filters_to_members(): void
+ {
+ $prod = ServerGroup::create(['name' => 'prod', 'color' => 'online']);
+ $member = Server::create(['name' => 'm', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
+ $outsider = Server::create(['name' => 'o', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
+ $prod->servers()->attach($member->id);
+
+ $ids = Server::inGroup($prod->id)->pluck('id');
+
+ $this->assertTrue($ids->contains($member->id));
+ $this->assertFalse($ids->contains($outsider->id));
+ }
+
+ public function test_deleting_a_group_detaches_its_servers_without_deleting_them(): void
+ {
+ $prod = ServerGroup::create(['name' => 'prod', 'color' => 'online']);
+ $server = Server::create(['name' => 'm', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
+ $prod->servers()->attach($server->id);
+
+ $prod->delete();
+
+ $this->assertDatabaseHas('servers', ['id' => $server->id]); // server survives
+ $this->assertDatabaseMissing('server_server_group', ['server_id' => $server->id]); // pivot gone
+ }
+}
diff --git a/tests/Feature/ServerGroupsComponentTest.php b/tests/Feature/ServerGroupsComponentTest.php
new file mode 100644
index 0000000..e935dd7
--- /dev/null
+++ b/tests/Feature/ServerGroupsComponentTest.php
@@ -0,0 +1,184 @@
+create(['must_change_password' => false]);
+ }
+
+ private function operator(): User
+ {
+ return User::factory()->operator()->create(['must_change_password' => false]);
+ }
+
+ private function viewer(): User
+ {
+ return User::factory()->viewer()->create(['must_change_password' => false]);
+ }
+
+ // ── route gating (manage-fleet = admin only) ────────────────────────────────
+
+ public function test_viewer_cannot_open_the_groups_page(): void
+ {
+ $this->actingAs($this->viewer())->get('/servers/groups')->assertForbidden();
+ }
+
+ public function test_operator_cannot_open_the_groups_page(): void
+ {
+ $this->actingAs($this->operator())->get('/servers/groups')->assertForbidden();
+ }
+
+ public function test_admin_can_open_the_groups_page(): void
+ {
+ $this->actingAs($this->admin())->get('/servers/groups')->assertOk();
+ }
+
+ // ── create ──────────────────────────────────────────────────────────────────
+
+ public function test_admin_creates_a_group_and_it_is_audited(): void
+ {
+ $this->actingAs($this->admin());
+
+ Livewire::test(Groups::class)
+ ->set('name', 'Produktion')
+ ->set('color', 'online')
+ ->call('create')
+ ->assertOk()
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseHas('server_groups', ['name' => 'Produktion', 'color' => 'online']);
+ $this->assertDatabaseHas('audit_events', ['action' => 'group.create', 'target' => 'Produktion']);
+ }
+
+ public function test_create_rejects_a_duplicate_name(): void
+ {
+ $this->actingAs($this->admin());
+ ServerGroup::create(['name' => 'prod', 'color' => 'accent']);
+
+ Livewire::test(Groups::class)
+ ->set('name', 'prod')
+ ->call('create')
+ ->assertHasErrors('name');
+
+ $this->assertSame(1, ServerGroup::where('name', 'prod')->count());
+ }
+
+ public function test_create_rejects_an_unknown_color_token(): void
+ {
+ $this->actingAs($this->admin());
+
+ Livewire::test(Groups::class)
+ ->set('name', 'x')
+ ->set('color', 'rebeccapurple')
+ ->call('create')
+ ->assertHasErrors('color');
+
+ $this->assertDatabaseCount('server_groups', 0);
+ }
+
+ // ── rename / recolour / membership ──────────────────────────────────────────
+
+ public function test_admin_renames_and_recolors_a_group(): void
+ {
+ $this->actingAs($this->admin());
+ $g = ServerGroup::create(['name' => 'old', 'color' => 'accent']);
+
+ Livewire::test(Groups::class)
+ ->call('rename', $g->uuid, 'Neu')
+ ->call('setColor', $g->uuid, 'cyan');
+
+ $this->assertDatabaseHas('server_groups', ['id' => $g->id, 'name' => 'Neu', 'color' => 'cyan']);
+ }
+
+ public function test_set_color_ignores_an_unknown_token(): void
+ {
+ $this->actingAs($this->admin());
+ $g = ServerGroup::create(['name' => 'g', 'color' => 'accent']);
+
+ Livewire::test(Groups::class)->call('setColor', $g->uuid, 'rebeccapurple');
+
+ $this->assertSame('accent', $g->fresh()->color); // unchanged
+ }
+
+ public function test_toggle_member_adds_then_removes_a_server(): void
+ {
+ $this->actingAs($this->admin());
+ $g = ServerGroup::create(['name' => 'g', 'color' => 'accent']);
+ $s = Server::create(['name' => 's', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
+
+ Livewire::test(Groups::class)->call('toggleMember', $g->uuid, $s->uuid);
+ $this->assertTrue($g->servers()->where('servers.id', $s->id)->exists());
+
+ Livewire::test(Groups::class)->call('toggleMember', $g->uuid, $s->uuid);
+ $this->assertFalse($g->servers()->where('servers.id', $s->id)->exists());
+ }
+
+ // ── delete via signed confirm token ─────────────────────────────────────────
+
+ public function test_admin_deletes_a_group_via_a_confirmed_token(): void
+ {
+ $this->actingAs($this->admin());
+ $g = ServerGroup::create(['name' => 'gone', 'color' => 'offline']);
+
+ $token = ConfirmToken::issue('groupConfirmed', ['uuid' => $g->uuid], 'group.delete', $g->name, null);
+ ConfirmToken::confirm($token); // the modal confirmation step
+
+ Livewire::test(Groups::class)
+ ->call('deleteGroup', $token)
+ ->assertOk();
+
+ $this->assertDatabaseMissing('server_groups', ['id' => $g->id]);
+ }
+
+ public function test_a_forged_delete_token_is_a_no_op(): void
+ {
+ $this->actingAs($this->admin());
+ $g = ServerGroup::create(['name' => 'stay', 'color' => 'accent']);
+
+ Livewire::test(Groups::class)->call('deleteGroup', 'not-a-real-token');
+
+ $this->assertDatabaseHas('server_groups', ['id' => $g->id]);
+ }
+
+ // ── fleet filter (Servers\Index) — open to any role ─────────────────────────
+
+ public function test_group_filter_shows_only_members(): void
+ {
+ $this->actingAs($this->viewer()); // filtering is open to everyone
+ $g = ServerGroup::create(['name' => 'web', 'color' => 'cyan']);
+ $member = Server::create(['name' => 'web1', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
+ $other = Server::create(['name' => 'db1', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
+ $g->servers()->attach($member->id);
+
+ Livewire::test(ServersIndex::class)
+ ->set('group', $g->uuid)
+ ->assertSee('web1')
+ ->assertDontSee('db1');
+ }
+
+ public function test_unknown_group_filter_falls_back_to_all(): void
+ {
+ $this->actingAs($this->viewer());
+ Server::create(['name' => 'srv', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
+
+ Livewire::test(ServersIndex::class)
+ ->set('group', 'no-such-uuid')
+ ->assertOk()
+ ->assertSee('srv'); // not filtered out by a bogus group
+ }
+}