mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
9d82a76fc4
commit
9db8d55473
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user