feat(canvas): let admins drag server zones on the servers and dashboard canvases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-23 22:13:29 +02:00
co-authored by Claude Opus 5.5
parent 9d82a76fc4
commit 9db8d55473
11 changed files with 164 additions and 20 deletions
@@ -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.
*/
+3
View File
@@ -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',
];
/**
+12
View File
@@ -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}
*/
+1 -13
View File
@@ -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.
*
+8 -2
View File
@@ -25,7 +25,7 @@ class ServerCanvas
/**
* @param Collection<int, Server> $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<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, 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<int, array<string, mixed>>, nodes: array<int, array<string, mixed>>, links: array<int, array<string, mixed>>, 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(),
@@ -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,
@@ -0,0 +1,22 @@
<?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::table('servers', function (Blueprint $table) {
$table->json('canvas_position')->nullable();
});
}
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('canvas_position');
});
}
};
@@ -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 => {
+1 -1
View File
@@ -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,
+2
View File
@@ -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']);
+62
View File
@@ -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();
});