diff --git a/app/Http/Controllers/Api/ServerCanvasController.php b/app/Http/Controllers/Api/ServerCanvasController.php index 79c33553ad..7f1d152af8 100644 --- a/app/Http/Controllers/Api/ServerCanvasController.php +++ b/app/Http/Controllers/Api/ServerCanvasController.php @@ -4,16 +4,20 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Server; +use App\Services\EnvironmentCanvas; use App\Services\ServerCanvas; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; /** * Server canvas: every server of the team (or one server) with the resources on it. - * Moves and links are saved through EnvironmentCanvasController. Called by resources/js/environment-canvas.js through the /canvas/api session bridge. + * Moves of cards and links are saved through EnvironmentCanvasController; the servers and dashboard canvases save the place of a server here. Called by resources/js/environment-canvas.js through the /canvas/api session bridge. */ class ServerCanvasController extends Controller { + private const MAX_POSITIONS = 1000; + public function __construct(private ServerCanvas $canvas) {} public function index(Request $request): JsonResponse @@ -60,6 +64,50 @@ class ServerCanvasController extends Controller return response()->json($this->canvas->payload($teamId, collect([$server]), $server, $this->canReadSensitive($request), $this->canEdit($request, $teamId))); } + /** + * Saves the place of server zones on the servers and dashboard canvases (top-left of each zone), keyed by server UUID. + */ + public function updatePositions(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + $coordinateRule = ['required', 'integer', 'between:-'.EnvironmentCanvas::MAX_COORDINATE.','.EnvironmentCanvas::MAX_COORDINATE]; + $validator = Validator::make($request->all(), [ + 'positions' => ['required', 'array', 'max:'.self::MAX_POSITIONS], + 'positions.*' => ['required', 'array'], + 'positions.*.x' => $coordinateRule, + 'positions.*.y' => $coordinateRule, + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + // Servers of other teams are left out, like unknown UUIDs (for example the zone of resources without a server). + $servers = Server::query()->whereTeamId($teamId)->whereIn('uuid', array_keys($request->input('positions')))->get(); + foreach ($servers as $server) { + $this->authorize('update', $server); + } + + $saved = []; + foreach ($servers as $server) { + $position = $request->input("positions.{$server->uuid}"); + $server->canvas_position = $saved[$server->uuid] = ['x' => (int) $position['x'], 'y' => (int) $position['y']]; + $server->save(); + } + + return response()->json(['positions' => (object) $saved]); + } + /** * Same rule as the environment canvas: team admins with a write token move cards and draw links. */ diff --git a/app/Models/Server.php b/app/Models/Server.php index f07f881f03..e8d3e7c12a 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -133,6 +133,8 @@ class Server extends BaseModel public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::']; + protected array $auditExclude = ['canvas_position']; + /** * Serves the proxy error pages (503.html) for the Traefik and Caddy catch-all. */ @@ -340,6 +342,7 @@ class Server extends BaseModel 'delete_unused_networks' => 'boolean', 'force_disabled' => 'boolean', 'sentinel_waiting_since' => 'datetime', + 'canvas_position' => 'array', ]; /** diff --git a/app/Services/EnvironmentCanvas.php b/app/Services/EnvironmentCanvas.php index 8372af3371..6a7ffcea82 100644 --- a/app/Services/EnvironmentCanvas.php +++ b/app/Services/EnvironmentCanvas.php @@ -137,6 +137,18 @@ class EnvironmentCanvas ]; } + /** + * A saved zone position (projects and servers canvas), or null when the zone is auto placed. + * + * @return array{x: int, y: int}|null + */ + public function zonePosition(mixed $saved): ?array + { + return is_array($saved) && is_numeric($saved['x'] ?? null) && is_numeric($saved['y'] ?? null) + ? ['x' => (int) $saved['x'], 'y' => (int) $saved['y']] + : null; + } + /** * @return array{id: string, name: string, ip: ?string, is_reachable: bool, is_usable: bool, link: string} */ diff --git a/app/Services/ProjectCanvas.php b/app/Services/ProjectCanvas.php index 602e23dbe5..65fd7b2540 100644 --- a/app/Services/ProjectCanvas.php +++ b/app/Services/ProjectCanvas.php @@ -57,7 +57,7 @@ class ProjectCanvas 'name' => $project->name, 'icon' => 'projects', 'link' => route('project.show', ['project_uuid' => $project->uuid]), - 'position' => $this->position($project->canvas_position), + 'position' => $this->canvas->zonePosition($project->canvas_position), ])->values()->all(), 'nodes' => collect($graph['nodes'])->map(fn (array $node) => [...$node, ...$grouping->get($node['id'])]) ->concat($projects->flatMap(fn (Project $project) => $this->emptyNodes($project, $environments->where('project_id', $project->id), $resources, $project->uuid, $canEdit))) @@ -124,18 +124,6 @@ class ProjectCanvas ->values(); } - /** - * A saved project zone position, or null when the zone is auto placed. - * - * @return array{x: int, y: int}|null - */ - private function position(mixed $saved): ?array - { - return is_array($saved) && is_numeric($saved['x'] ?? null) && is_numeric($saved['y'] ?? null) - ? ['x' => (int) $saved['x'], 'y' => (int) $saved['y']] - : null; - } - /** * Project and environment of a card: the canvas saves links on that environment canvas. * diff --git a/app/Services/ServerCanvas.php b/app/Services/ServerCanvas.php index 9eaf12c39a..afee3cece6 100644 --- a/app/Services/ServerCanvas.php +++ b/app/Services/ServerCanvas.php @@ -25,7 +25,7 @@ class ServerCanvas /** * @param Collection $servers servers of the team to show - * @return array{title: string, title_link: ?string, can_edit: bool, can_move: false, can_move_groups: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array{}} + * @return array{title: string, title_link: ?string, can_edit: bool, can_move: false, can_move_groups: bool, can_move_zones: bool, layout: 'server', zones: array>, nodes: array>, links: array>, positions: array{}} */ public function payload(int $teamId, Collection $servers, ?Server $single, bool $canReadSensitive, bool $canEdit = false): array { @@ -92,9 +92,15 @@ class ServerCanvas 'can_move' => false, // Admins drag the project / environment group boxes; the canvas saves the moved cards on their environment canvas. 'can_move_groups' => $canEdit, + // Admins move the server zones of the every-server canvases; each server saves its place (zone.position). + 'can_move_zones' => $canEdit && ! $single, // Tells the canvas to group cards by project / environment and place linked cards side by side. 'layout' => 'server', - 'zones' => $servers->map(fn (Server $server) => [...$this->canvas->zone($server, $canReadSensitive), 'details' => $this->details($server)]) + 'zones' => $servers->map(fn (Server $server) => [ + ...$this->canvas->zone($server, $canReadSensitive), + 'details' => $this->details($server), + 'position' => $single ? null : $this->canvas->zonePosition($server->canvas_position), + ]) ->when($empty->contains('zone', EnvironmentCanvas::EMPTY_ZONE), fn (Collection $zones) => $zones->push($this->canvas->emptyZone())) ->values()->all(), 'nodes' => $nodes->concat($empty)->concat($bare)->all(), diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php index c37bf61382..47efbb767f 100644 --- a/app/Services/ServerTransfer/ServerTransferCoverage.php +++ b/app/Services/ServerTransfer/ServerTransferCoverage.php @@ -185,6 +185,7 @@ final class ServerTransferCoverage 'hetzner_server_status' => self::RUNTIME, 'vultr_instance_status' => self::RUNTIME, 'digitalocean_droplet_status' => self::RUNTIME, 'hostinger_virtual_machine_status' => self::RUNTIME, 'server_metadata' => 'Facts about the host and the transfer state of the source; the target collects its own.', + 'canvas_position' => 'Place of the server on the servers canvas of the source; the target places it with its own servers.', ], ServerSetting::class => [ 'server_id' => self::LINKED, 'is_reachable' => self::RUNTIME, 'is_usable' => self::RUNTIME, 'force_disabled' => self::RUNTIME, diff --git a/database/migrations/2026_10_23_200902_add_canvas_position_to_servers_table.php b/database/migrations/2026_10_23_200902_add_canvas_position_to_servers_table.php new file mode 100644 index 0000000000..efef6d1cfe --- /dev/null +++ b/database/migrations/2026_10_23_200902_add_canvas_position_to_servers_table.php @@ -0,0 +1,22 @@ +json('canvas_position')->nullable(); + }); + } + + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('canvas_position'); + }); + } +}; diff --git a/resources/js/canvas/environment/EnvironmentCanvas.svelte b/resources/js/canvas/environment/EnvironmentCanvas.svelte index d6690c9632..76b63611a7 100644 --- a/resources/js/canvas/environment/EnvironmentCanvas.svelte +++ b/resources/js/canvas/environment/EnvironmentCanvas.svelte @@ -120,7 +120,7 @@ /** Server view: key of the card's group when the card can be moved there (it is saved on its environment canvas). */ const movableGroupOf = node => (canMoveGroups && node ? groupKey([node], node.zone) : null); let groupMeta = {}; // server view: frame top-left and zone inner top-left per group key (see layoutServerView) - // Projects canvas: admins drag the project zones; each project saves the zone's top-left (zone.position). + // Projects, servers and dashboard canvases: admins drag the zones; each project or server saves its top-left (zone.position). const canMoveZones = $derived(canEdit && !!graph?.can_move_zones); let zoneOrigins = {}; // server view: top-left per zone id (see layoutServerView) // Environment canvas endpoints of a card (the server view has no endpoints of its own for changes). @@ -540,7 +540,7 @@ } /** - * Projects canvas: saves the place of a moved zone. Zones that are still placed automatically are saved where they + * Projects, servers and dashboard canvases: saves the place of a moved zone. Zones that are still placed automatically are saved where they * are too, so they do not move into the space the zone left. */ async function saveZones(id, delta) { @@ -571,7 +571,7 @@ const starts = Object.fromEntries(ids.map(id => [id, { ...positions[id] }])); const box = toBox(frame); const others = frames.filter(f => f !== frame && f.zone === frame.zone).map(toBox); - // Projects canvas: zones can sit anywhere, so the group's zone must not grow over another zone. + // Movable zones can sit anywhere, so the group's zone must not grow over another zone. const otherZones = canMoveZones ? boxes.filter(z => z.id !== frame.zone).map(toBox) : []; const top = ZONE_PADDING + zoneHeader(boxes.find(z => z.id === frame.zone)?.zone); const zoneAround = b => { diff --git a/resources/js/environment-canvas.js b/resources/js/environment-canvas.js index 8da3789d05..3b8cbe49fc 100644 --- a/resources/js/environment-canvas.js +++ b/resources/js/environment-canvas.js @@ -229,7 +229,7 @@ export const groupKey = (nodes, zoneId) => { }; /** - * Server view: zones stack downwards; a zone with a saved place (zone.position, projects canvas) sits there and the + * Server view: zones stack downwards; a zone with a saved place (zone.position: projects, servers and dashboard canvases) sits there and the * stacked zones keep clear of it. A group with cards saved on its environment canvas (node.saved) sits where * those cards are, so the server canvas and the environment canvas share one position (a group that would * leave the zone or cover another group moves to the nearest free spot). Other groups follow below, diff --git a/routes/web.php b/routes/web.php index 22d2c5e1e8..c5a782725b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -223,7 +223,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::delete('/shared-variables/{scope}/{uuid}/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); Route::get('/servers', [ServersController::class, 'servers'])->middleware(['api.ability:read']); Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['api.ability:read']); + Route::patch('/servers/canvas/positions', [ServerCanvasController::class, 'updatePositions'])->middleware(['api.ability:write']); Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['api.ability:read']); + Route::patch('/dashboard/canvas/positions', [ServerCanvasController::class, 'updatePositions'])->middleware(['api.ability:write']); Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['api.ability:read']); diff --git a/tests/Feature/ServerCanvasTest.php b/tests/Feature/ServerCanvasTest.php index e98e8a0dcb..a3ec0b4bd0 100644 --- a/tests/Feature/ServerCanvasTest.php +++ b/tests/Feature/ServerCanvasTest.php @@ -412,3 +412,65 @@ test('only admins may move the environment groups on the server canvas', functio serverCanvasSession($this, $member, $this->team)->getJson('/canvas/api/servers/canvas') ->assertJsonPath('can_move_groups', false); }); + +test('an admin moves server zones on the servers and dashboard canvases and both return their place', function () { + // Another team's server with the same request stays as it is. + $foreign = Server::factory()->create(['team_id' => Team::factory()->create()->id]); + + serverCanvasSession($this, $this->admin, $this->team)->getJson('/canvas/api/servers/canvas') + ->assertJsonPath('can_move_zones', true) + ->assertJsonPath('zones.0.position', null); + + serverCanvasSession($this, $this->admin, $this->team) + ->patchJson('/canvas/api/dashboard/canvas/positions', ['positions' => [ + $this->web->uuid => ['x' => 640, 'y' => -120], + $foreign->uuid => ['x' => 5, 'y' => 5], + 'no-server' => ['x' => 0, 'y' => 0], + ]]) + ->assertOk() + ->assertJsonPath('positions', [$this->web->uuid => ['x' => 640, 'y' => -120]]); + serverCanvasSession($this, $this->admin, $this->team) + ->patchJson('/canvas/api/servers/canvas/positions', ['positions' => [$this->db->uuid => ['x' => 0, 'y' => 0]]]) + ->assertOk(); + + expect($foreign->fresh()->canvas_position)->toBeNull(); + // Zones are sorted by name: db-server, web-server. + foreach (['/canvas/api/servers/canvas', '/canvas/api/dashboard/canvas'] as $endpoint) { + serverCanvasSession($this, $this->admin, $this->team)->getJson($endpoint) + ->assertJsonPath('zones.0.position', ['x' => 0, 'y' => 0]) + ->assertJsonPath('zones.1.position', ['x' => 640, 'y' => -120]); + } + // The one server canvas has a single zone, which stays in its place. + serverCanvasSession($this, $this->admin, $this->team)->getJson("/canvas/api/servers/{$this->web->uuid}/canvas") + ->assertJsonPath('can_move_zones', false) + ->assertJsonPath('zones.0.position', null); +}); + +test('server zone positions must be whole coordinates on the canvas', function () { + serverCanvasSession($this, $this->admin, $this->team) + ->patchJson('/canvas/api/servers/canvas/positions', ['positions' => [$this->web->uuid => ['x' => 'left', 'y' => 999999999]]]) + ->assertUnprocessable() + ->assertJsonValidationErrors(["positions.{$this->web->uuid}.x", "positions.{$this->web->uuid}.y"]); + + expect($this->web->fresh()->canvas_position)->toBeNull(); +}); + +test('members and other teams cannot move a server zone', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + serverCanvasSession($this, $member, $this->team)->getJson('/canvas/api/dashboard/canvas') + ->assertJsonPath('can_move_zones', false); + serverCanvasSession($this, $member, $this->team) + ->patchJson('/canvas/api/servers/canvas/positions', ['positions' => [$this->web->uuid => ['x' => 10, 'y' => 10]]]) + ->assertForbidden(); + + // Admin of this team, owner of another team, with the session switched to the other team. + $otherTeam = Team::factory()->create(['show_boarding' => false]); + $otherTeam->members()->attach($this->admin->id, ['role' => 'owner']); + serverCanvasSession($this, $this->admin, $otherTeam) + ->patchJson('/canvas/api/dashboard/canvas/positions', ['positions' => [$this->web->uuid => ['x' => 10, 'y' => 10]]]) + ->assertOk() + ->assertJsonPath('positions', []); + + expect($this->web->fresh()->canvas_position)->toBeNull(); +});