feat(fleet): server groups — organise the fleet + filter (feature 1/8)
Foundation for the feature program (groups are the scoping primitive alerts, ad-hoc
commands, bulk actions and patch/posture will target). Lean by design: named groups,
no hierarchy, no per-group RBAC.
- server_groups (uuid route key, unique name, color = a @theme token key coerced to a real
token on save) + server_server_group M2M pivot (cascade on delete; servers survive a group
delete). Server::groups() + a `scopeInGroup`.
- Servers\Groups full-page component, manage-fleet (admin) only — route can:-mw + mount()
abort_unless + a per-method gate() on every mutation (create/rename/setColor/toggleMember/
confirmDelete/deleteGroup), since /livewire/update does not re-run route mw. create/rename/
recolour/membership audit directly; DELETE goes through the signed single-use ConfirmToken +
the R5 confirm modal (which writes group.delete from the sealed descriptor, so the handler
does not double-audit). 'groupConfirmed' added to ConfirmToken::ACTIONS.
- /servers/groups is registered BEFORE /servers/{server} so "groups" isn't captured as a uuid.
- Servers\Index: a #[Url] group filter (uuid; shareable) — an unknown uuid falls back to all,
never errors. Filtering is open to every role; only management is admin-gated. A "Gruppen"
button (admin) on the fleet header; colour pills use a literal @theme token map (R3).
- lang/{de,en}/groups.php + audit.php group.* labels (de/en parity). No emoji.
19 new tests (model + component: uuid/colour-allow-list/relations/cascade, RBAC route gating,
create/rename/recolour/membership, delete-via-confirmed-token, forged-token no-op, filter).
638 tests green, Pint, lang parity, Codex-reviewed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
parent
aa854916ce
commit
40d8dfc96d
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Servers;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerGroup;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Manage server groups (create / rename / recolour / delete + membership). Admin-only
|
||||
* (manage-fleet, matching server create/delete): the route mw gates it AND every mutating
|
||||
* method re-checks, since /livewire/update does not re-run route middleware. Every change is
|
||||
* audited; deletion goes through the signed, single-use ConfirmToken + the R5 confirm modal.
|
||||
*/
|
||||
#[Layout('layouts.app')]
|
||||
class Groups extends Component
|
||||
{
|
||||
/** New-group form. */
|
||||
public string $name = '';
|
||||
|
||||
public string $color = 'accent';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(Auth::user()?->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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <title>; 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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ServerGroup extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
/** Allowed @theme colour token keys for a group's dot/pill (R3 — never a raw hex). */
|
||||
public const COLORS = ['accent', 'cyan', 'online', 'warning', 'offline', 'ink'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $g) => $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);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@ class ConfirmToken
|
|||
'releaseStaged',
|
||||
'releasePublic',
|
||||
'releaseYank',
|
||||
'groupConfirmed',
|
||||
];
|
||||
|
||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('server_groups', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => '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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => '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.',
|
||||
];
|
||||
|
|
@ -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
|
||||
|
||||
<div class="space-y-5">
|
||||
{{-- Header --}}
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('groups.eyebrow') }}</p>
|
||||
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('groups.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-ink-3">{{ __('groups.subtitle') }}</p>
|
||||
</div>
|
||||
<a href="{{ route('servers.index') }}" wire:navigate
|
||||
class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:bg-raised hover:text-ink">
|
||||
<x-icon name="server" class="h-3.5 w-3.5" /> {{ __('groups.back_to_servers') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- New group --}}
|
||||
<x-panel :title="__('groups.new_group')">
|
||||
<form wire:submit="create" class="grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end">
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('groups.name_label') }}</label>
|
||||
<input wire:model="name" type="text" maxlength="60" placeholder="{{ __('groups.name_placeholder') }}"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('name') <p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('groups.color_label') }}</label>
|
||||
<div class="flex h-9 items-center gap-1.5">
|
||||
@foreach ($colors as $c)
|
||||
<button type="button" wire:click="$set('color', '{{ $c }}')"
|
||||
aria-label="{{ $c }}" aria-pressed="{{ $color === $c ? 'true' : 'false' }}"
|
||||
@class(['grid h-6 w-6 place-items-center rounded-full transition', 'ring-2 ring-offset-2 ring-offset-surface ring-ink' => $color === $c])>
|
||||
<span class="h-3.5 w-3.5 rounded-full {{ $dot[$c] }}"></span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<x-btn variant="primary" size="lg" type="submit">{{ __('groups.create') }}</x-btn>
|
||||
</form>
|
||||
</x-panel>
|
||||
|
||||
{{-- Groups list --}}
|
||||
@if ($groups->isEmpty())
|
||||
<x-panel>
|
||||
<div class="flex flex-col items-center gap-2 py-8 text-center">
|
||||
<x-icon name="folder" class="h-6 w-6 text-ink-4" />
|
||||
<p class="font-display text-sm font-semibold text-ink">{{ __('groups.empty_title') }}</p>
|
||||
<p class="max-w-sm text-xs text-ink-3">{{ __('groups.empty') }}</p>
|
||||
</div>
|
||||
</x-panel>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
@foreach ($groups as $g)
|
||||
<x-panel wire:key="group-{{ $g->uuid }}">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="h-3 w-3 shrink-0 rounded-full {{ $dot[$g->color] ?? 'bg-accent' }}"></span>
|
||||
<input type="text" value="{{ $g->name }}" maxlength="60"
|
||||
wire:change="rename('{{ $g->uuid }}', $event.target.value)"
|
||||
class="min-w-0 flex-1 rounded-md border border-transparent bg-transparent px-2 py-1 font-display text-sm font-semibold text-ink hover:border-line focus:border-accent/40 focus:bg-inset focus:outline-none" />
|
||||
<span class="shrink-0 font-mono text-[11px] text-ink-3">{{ trans_choice('groups.members_count', $g->servers_count, ['count' => $g->servers_count]) }}</span>
|
||||
|
||||
{{-- Recolour --}}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
@foreach ($colors as $c)
|
||||
<button type="button" wire:click="setColor('{{ $g->uuid }}', '{{ $c }}')"
|
||||
aria-label="{{ $c }}" aria-pressed="{{ $g->color === $c ? 'true' : 'false' }}"
|
||||
@class(['grid h-5 w-5 place-items-center rounded-full transition', 'ring-2 ring-offset-2 ring-offset-surface ring-ink' => $g->color === $c])>
|
||||
<span class="h-3 w-3 rounded-full {{ $dot[$c] }}"></span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<x-modal-trigger variant="danger-soft" action="confirmDelete('{{ $g->uuid }}')">{{ __('common.delete') }}</x-modal-trigger>
|
||||
</div>
|
||||
|
||||
{{-- Membership grid --}}
|
||||
<div class="mt-4 border-t border-line pt-4">
|
||||
<p class="mb-2 font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('groups.assign') }}</p>
|
||||
@if ($servers->isEmpty())
|
||||
<p class="font-mono text-[11px] text-ink-3">{{ __('groups.no_servers') }}</p>
|
||||
@else
|
||||
<div class="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@php($memberIds = $g->servers->pluck('id'))
|
||||
@foreach ($servers as $s)
|
||||
<label class="flex min-h-11 items-center gap-2.5 rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink-2 transition-colors hover:bg-raised sm:min-h-0">
|
||||
<input type="checkbox" wire:key="member-{{ $g->uuid }}-{{ $s->uuid }}"
|
||||
@checked($memberIds->contains($s->id))
|
||||
wire:click="toggleMember('{{ $g->uuid }}', '{{ $s->uuid }}')"
|
||||
class="h-4 w-4 shrink-0 rounded border-line bg-inset text-accent focus:ring-accent/40" />
|
||||
<span class="min-w-0 truncate">{{ $s->name }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-panel>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -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
|
||||
|
||||
<div class="space-y-4">
|
||||
|
|
@ -11,6 +13,10 @@
|
|||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@can('manage-fleet')
|
||||
<a href="{{ route('servers.groups') }}" wire:navigate
|
||||
class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:bg-raised hover:text-ink">
|
||||
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('groups.title') }}
|
||||
</a>
|
||||
<x-modal-trigger variant="accent" component="modals.create-server">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" />
|
||||
{{ __('servers.add_server') }}
|
||||
|
|
@ -20,6 +26,30 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Group filter (only when groups exist) --}}
|
||||
@if ($groups->isNotEmpty())
|
||||
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="{{ __('groups.filter_label') }}">
|
||||
<button type="button" wire:click="$set('group', null)"
|
||||
@class([
|
||||
'inline-flex min-h-9 items-center rounded-md border px-3 text-xs transition-colors',
|
||||
'border-accent/25 bg-accent/10 text-ink' => ! $activeGroup,
|
||||
'border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink' => $activeGroup,
|
||||
])>{{ __('groups.filter_all') }}</button>
|
||||
@foreach ($groups as $g)
|
||||
<button type="button" wire:click="$set('group', '{{ $g->uuid }}')"
|
||||
@class([
|
||||
'inline-flex min-h-9 items-center gap-1.5 rounded-md border px-3 text-xs transition-colors',
|
||||
'border-accent/25 bg-accent/10 text-ink' => $activeGroup?->id === $g->id,
|
||||
'border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink' => $activeGroup?->id !== $g->id,
|
||||
])>
|
||||
<span class="h-2 w-2 rounded-full {{ $dot[$g->color] ?? 'bg-accent' }}"></span>
|
||||
{{ $g->name }}
|
||||
<span class="font-mono text-[10px] text-ink-4">{{ $g->servers_count }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- KPI grid: 1 → 2 → 4 --}}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<x-kpi :label="__('servers.kpi_total')" :value="$total" icon="server" />
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerGroup;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServerGroupModelTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_a_group_gets_a_uuid_route_key(): void
|
||||
{
|
||||
$g = ServerGroup::create(['name' => '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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Servers\Groups;
|
||||
use App\Livewire\Servers\Index as ServersIndex;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerGroup;
|
||||
use App\Models\User;
|
||||
use App\Support\Confirm\ConfirmToken;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServerGroupsComponentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
return User::factory()->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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue