feat(canvas): add interactive resource views and team avatars (#12160)

This commit is contained in:
Andras Bacsai
2026-10-14 09:28:10 +02:00
committed by GitHub
parent 448dc8ea0a
commit 33ee09fa87
146 changed files with 12076 additions and 367 deletions
@@ -121,8 +121,8 @@ trait HandlesTagsApi
return response()->json(['message' => 'Tag not found on resource.'], 404);
}
// The tag stays, also without resources.
$resource->tags()->detach($tag->id);
$tag->deleteIfOrphaned();
return response()->json(['message' => 'Tag removed.']);
}
@@ -0,0 +1,264 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Environment;
use App\Models\Project;
use App\Models\ResourceLink;
use App\Services\EnvironmentCanvas;
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
/**
* Environment canvas: a view of one environment's resources with saved card positions and visual-only links.
*/
class EnvironmentCanvasController extends Controller
{
private const MAX_POSITIONS = 1000;
public function __construct(private EnvironmentCanvas $canvas) {}
public function show(Request $request): JsonResponse
{
$environment = $this->findEnvironment($request);
if ($environment instanceof JsonResponse) {
return $environment;
}
$this->authorize('view', $environment);
$user = $request->user();
$token = $user->currentAccessToken();
$canEdit = $user->isAdminOfTeam((int) $environment->project->team_id)
&& ($token->can('root') || $token->can('write'));
return response()->json($this->canvas->payload(
$environment,
canReadSensitive: (bool) $request->attributes->get('can_read_sensitive', false),
canEdit: $canEdit,
));
}
public function updatePositions(Request $request): JsonResponse
{
$environment = $this->findEnvironment($request);
if ($environment instanceof JsonResponse) {
return $environment;
}
$this->authorize('update', $environment);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
'positions' => ['present', 'array', 'max:'.self::MAX_POSITIONS],
]);
if ($response = $this->validationFailedResponse($request, $validator, ['positions'])) {
return $response;
}
$nodeUuids = $this->canvas->resourceIndex($environment)->keys()->all();
$positions = collect($request->input('positions'))->only($nodeUuids)->all();
$coordinateRule = ['required', 'integer', 'between:-'.EnvironmentCanvas::MAX_COORDINATE.','.EnvironmentCanvas::MAX_COORDINATE];
$validator = Validator::make(['positions' => $positions], [
'positions.*' => ['required', 'array'],
'positions.*.x' => $coordinateRule,
'positions.*.y' => $coordinateRule,
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$saved = DB::transaction(function () use ($environment, $positions, $nodeUuids) {
$locked = Environment::query()->lockForUpdate()->findOrFail($environment->id);
$merged = $this->canvas->filterPositions(
array_merge($locked->canvas_positions ?? [], $positions),
$nodeUuids,
);
$locked->canvas_positions = $merged;
$locked->save();
return $merged;
});
return response()->json(['positions' => (object) $saved]);
}
public function storeLink(Request $request): JsonResponse
{
$environment = $this->findEnvironment($request);
if ($environment instanceof JsonResponse) {
return $environment;
}
$this->authorize('update', $environment);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
'source' => ['required', 'string', 'max:255'],
'target' => ['required', 'string', 'max:255', 'different:source'],
'label' => ['nullable', 'string', 'max:64'],
]);
if ($response = $this->validationFailedResponse($request, $validator, ['source', 'target', 'label'])) {
return $response;
}
$resources = $this->canvas->resourceIndex($environment);
$source = $resources->get($request->input('source'));
$target = $resources->get($request->input('target'));
$errors = [];
if (! $source) {
$errors['source'] = ['The source must be a resource of this environment.'];
}
if (! $target) {
$errors['target'] = ['The target must be a resource of this environment.'];
}
if ($errors !== []) {
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$attributes = [
'environment_id' => $environment->id,
'source_type' => $source['type'],
'source_id' => $source['id'],
'target_type' => $target['type'],
'target_id' => $target['id'],
];
$duplicate = ['message' => 'Validation failed.', 'errors' => ['target' => ['These resources are already linked.']]];
if (ResourceLink::query()->where($attributes)->exists()) {
return response()->json($duplicate, 422);
}
try {
$link = ResourceLink::create($attributes + [
'label' => $this->normalizeLabel($request->input('label')),
'created_by' => $request->user()->id,
]);
} catch (UniqueConstraintViolationException) {
return response()->json($duplicate, 422);
}
return response()->json(
$this->canvas->linkPayload($link, $request->input('source'), $request->input('target')),
201,
);
}
public function updateLink(Request $request): JsonResponse
{
$environment = $this->findEnvironment($request);
if ($environment instanceof JsonResponse) {
return $environment;
}
$this->authorize('update', $environment);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
'label' => ['present', 'nullable', 'string', 'max:64'],
]);
if ($response = $this->validationFailedResponse($request, $validator, ['label'])) {
return $response;
}
$link = $environment->resourceLinks()->whereUuid($request->route('link_uuid'))->first();
if (! $link) {
return response()->json(['message' => 'Link not found.'], 404);
}
$resources = $this->canvas->resourceIndex($environment);
$uuidOf = fn (string $type, int $id) => $resources->search(fn (array $resource) => $resource['type'] === $type && $resource['id'] === $id);
$sourceUuid = $uuidOf($link->source_type, (int) $link->source_id);
$targetUuid = $uuidOf($link->target_type, (int) $link->target_id);
if ($sourceUuid === false || $targetUuid === false) {
return response()->json(['message' => 'Link not found.'], 404);
}
$link->update(['label' => $this->normalizeLabel($request->input('label'))]);
return response()->json($this->canvas->linkPayload($link, $sourceUuid, $targetUuid));
}
public function destroyLink(Request $request): JsonResponse
{
$environment = $this->findEnvironment($request);
if ($environment instanceof JsonResponse) {
return $environment;
}
$this->authorize('update', $environment);
$link = $environment->resourceLinks()->whereUuid($request->route('link_uuid'))->first();
if (! $link) {
return response()->json(['message' => 'Link not found.'], 404);
}
$link->delete();
return response()->json(['message' => 'Link deleted.']);
}
/**
* The environment of the request, within a project of the token's team.
*/
private function findEnvironment(Request $request): Environment|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$project = Project::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$environmentNameOrUuid = $request->route('environment_name_or_uuid');
$environment = $project->environments()->whereName($environmentNameOrUuid)->first()
?? $project->environments()->whereUuid($environmentNameOrUuid)->first();
if (! $environment) {
return response()->json(['message' => 'Environment not found.'], 404);
}
$environment->setRelation('project', $project);
return $environment;
}
/**
* @param array<int, string> $allowedFields
*/
private function validationFailedResponse(Request $request, ValidatorContract $validator, array $allowedFields): ?JsonResponse
{
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if (! $validator->fails() && empty($extraFields)) {
return null;
}
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
private function normalizeLabel(mixed $label): ?string
{
$label = trim((string) $label);
return $label === '' ? null : $label;
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Project;
use App\Services\ProjectCanvas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Project canvas: the resources of every environment of one project (or of every project).
* Moves and links are saved through EnvironmentCanvasController. Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
*/
class ProjectCanvasController extends Controller
{
public function __construct(private ProjectCanvas $canvas) {}
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
return response()->json($this->canvas->teamPayload((int) $teamId, (bool) $request->attributes->get('can_read_sensitive', false), $this->canEdit($request, (int) $teamId)));
}
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$project = Project::query()->whereTeamId($teamId)->whereUuid($uuid)->first();
if (! $project) {
return response()->json(['message' => 'Project not found.'], 404);
}
$this->authorize('view', $project);
return response()->json($this->canvas->payload($project, (bool) $request->attributes->get('can_read_sensitive', false), $this->canEdit($request, (int) $project->team_id)));
}
/**
* Same rule as the environment canvas: team admins with a write token move cards and draw links.
*/
private function canEdit(Request $request, int $teamId): bool
{
$user = $request->user();
$token = $user->currentAccessToken();
return $user->isAdminOfTeam($teamId) && ($token->can('root') || $token->can('write'));
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Services\ServerCanvas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 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.
*/
class ServerCanvasController extends Controller
{
public function __construct(private ServerCanvas $canvas) {}
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('viewAny', Server::class);
$servers = Server::query()->whereTeamId($teamId)->with('settings')->orderBy('name')->get();
return response()->json($this->canvas->payload($teamId, $servers, null, $this->canReadSensitive($request), $this->canEdit($request, $teamId)));
}
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('view', $server);
return response()->json($this->canvas->payload($teamId, collect([$server]), $server, $this->canReadSensitive($request), $this->canEdit($request, $teamId)));
}
/**
* Same rule as the environment canvas: team admins with a write token move cards and draw links.
*/
private function canEdit(Request $request, int $teamId): bool
{
$user = $request->user();
$token = $user->currentAccessToken();
return $user->isAdminOfTeam($teamId) && ($token->can('root') || $token->can('write'));
}
/**
* Unmanaged Docker containers of one server, as read-only cards for its canvas.
*/
public function containers(string $uuid): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first();
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('view', $server);
return response()->json(['nodes' => $this->canvas->unmanagedContainerNodes($server)->all()]);
}
private function canReadSensitive(Request $request): bool
{
return (bool) $request->attributes->get('can_read_sensitive', false);
}
}
@@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Services\SharedVariableCanvas;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
/**
* Shared variables canvas: shows which resources use the shared variables of a scope, and adds
* a shared variable to a resource (a resource variable that holds {{scope.KEY}}) or removes it.
* Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
*/
class SharedVariableCanvasController extends Controller
{
use EnvironmentVariableProtection;
public function __construct(private SharedVariableCanvas $canvas) {}
public function show(Request $request): JsonResponse
{
$scope = $this->scope($request);
$owner = $this->findOwner($scope, $request->route('uuid'));
if ($owner instanceof JsonResponse) {
return $owner;
}
$this->authorize('view', $owner);
return response()->json($this->canvas->payload($scope, $owner, $this->canEdit($request, $scope, $owner)));
}
public function storeLink(Request $request): JsonResponse
{
$scope = $this->scope($request);
$owner = $this->findOwner($scope, $request->route('uuid'));
if ($owner instanceof JsonResponse) {
return $owner;
}
$this->authorize('view', $owner);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
'source' => ['required', 'string', 'max:255'],
'target' => ['required', 'string', 'max:255'],
]);
if ($validator->fails()) {
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
}
$variable = $this->findVariable($owner, $request->input('source'));
$resource = $this->canvas->resources($scope, $owner)->get($request->input('target'));
$errors = [];
if (! $variable) {
$errors['source'] = ['The source must be a shared variable of this scope.'];
}
if (! $resource) {
$errors['target'] = ['The target must be a resource that can use this shared variable.'];
}
if ($errors !== []) {
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$this->authorize('manageEnvironment', $resource);
$existing = $this->canvas->resourceVariables(collect([$resource]))->firstWhere('key', $variable->key);
if ($existing) {
$message = in_array($variable->key, $this->canvas->referencedKeys($scope, $existing->value), true)
? "{$resource->name} already uses {$variable->key}."
: "{$resource->name} already has a variable named {$variable->key}. Rename or remove it first.";
return response()->json(['message' => $message, 'errors' => ['target' => [$message]]], 422);
}
$environmentVariable = new EnvironmentVariable;
$environmentVariable->key = $variable->key;
$environmentVariable->value = $this->canvas->reference($scope, $variable->key);
$environmentVariable->is_multiline = (bool) $variable->is_multiline;
$environmentVariable->is_literal = (bool) $variable->is_literal;
$environmentVariable->resourceable_id = $resource->getKey();
$environmentVariable->resourceable_type = $resource->getMorphClass();
$environmentVariable->save();
$link = collect($this->canvas->links($scope, collect([$variable]), collect([$resource->uuid => $resource])))
->firstWhere('source', $this->canvas->variableNodeId($variable));
return response()->json($link, 201);
}
public function destroyLink(Request $request): JsonResponse
{
$scope = $this->scope($request);
$owner = $this->findOwner($scope, $request->route('uuid'));
if ($owner instanceof JsonResponse) {
return $owner;
}
$this->authorize('view', $owner);
[$source, $target] = array_pad(explode('|', (string) $request->route('link_id'), 2), 2, '');
$resource = $this->canvas->resources($scope, $owner)->get($target);
$variable = $this->findVariable($owner, $source);
$key = $variable?->key ?? (str_starts_with($source, 'missing:') ? substr($source, strlen('missing:')) : null);
if (! $resource || blank($key)) {
return response()->json(['message' => 'Link not found.'], 404);
}
$this->authorize('manageEnvironment', $resource);
// Preview copies of application variables are removed together with the variable.
$references = EnvironmentVariable::query()
->where('resourceable_type', $resource->getMorphClass())
->where('resourceable_id', $resource->getKey())
->get()
->filter(fn (EnvironmentVariable $environmentVariable) => in_array($key, $this->canvas->referencedKeys($scope, $environmentVariable->value), true));
if ($references->where('is_preview', false)->isEmpty()) {
return response()->json(['message' => 'Link not found.'], 404);
}
$embedded = $references->reject(fn (EnvironmentVariable $environmentVariable) => $this->canvas->isExactReference($scope, $key, $environmentVariable->value));
if ($embedded->isNotEmpty()) {
$message = "{$embedded->pluck('key')->unique()->join(', ')} on {$resource->name} uses {$this->canvas->reference($scope, $key)} inside a longer value. Edit it on the resource.";
return response()->json(['message' => $message], 422);
}
if ($this->usesDockerCompose($resource)) {
foreach ($references->pluck('key')->unique() as $environmentKey) {
[$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($environmentKey, $resource->docker_compose);
if ($isUsed) {
return response()->json(['message' => "Cannot remove {$environmentKey} from {$resource->name}. {$reason}"], 422);
}
}
}
DB::transaction(fn () => $references->each->delete());
return response()->json(['message' => 'Link removed.']);
}
/**
* The team routes have no {scope} segment.
*/
private function scope(Request $request): string
{
return $request->route('scope') ?? 'team';
}
private function findOwner(string $scope, ?string $uuid): Model|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$owner = match ($scope) {
'team' => Team::query()->find($teamId),
'project' => Project::query()->where('team_id', $teamId)->where('uuid', $uuid)->first(),
'environment' => Environment::query()->where('uuid', $uuid)->whereHas('project', fn ($query) => $query->where('team_id', $teamId))->with('project')->first(),
'server' => Server::query()->where('team_id', $teamId)->where('uuid', $uuid)->first(),
default => null,
};
return $owner ?? response()->json(['message' => str($scope)->headline().' not found.'], 404);
}
private function findVariable(Model $owner, ?string $nodeId): ?SharedEnvironmentVariable
{
if (! str_starts_with((string) $nodeId, 'variable:')) {
return null;
}
return $owner->environment_variables()->whereKey((int) substr($nodeId, strlen('variable:')))->first();
}
private function canEdit(Request $request, string $scope, Model $owner): bool
{
$user = $request->user();
$token = $user->currentAccessToken();
return $user->isAdminOfTeam($this->canvas->teamId($scope, $owner))
&& ($token->can('root') || $token->can('write'));
}
private function usesDockerCompose(Model $resource): bool
{
return $resource instanceof Service
|| ($resource instanceof Application && $resource->build_pack === 'dockercompose');
}
}
@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use App\Services\TagCanvas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
/**
* Tags canvas: shows which resources have which tag, and tags or untags a resource.
* Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
*/
class TagCanvasController extends Controller
{
public function __construct(private TagCanvas $canvas) {}
public function show(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('viewAny', Tag::class);
$user = $request->user();
$token = $user->currentAccessToken();
$canEdit = $user->isAdminOfTeam((int) $teamId) && ($token->can('root') || $token->can('write'));
return response()->json($this->canvas->payload((int) $teamId, $canEdit));
}
public function storeLink(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
'source' => ['required', 'string', 'max:255'],
'target' => ['required', 'string', 'max:255'],
]);
if ($validator->fails()) {
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
}
$tag = $this->findTag((int) $teamId, $request->input('source'));
$resource = $this->canvas->resources((int) $teamId)->get($request->input('target'));
$errors = [];
if (! $tag) {
$errors['source'] = ['The source must be a tag of this team.'];
}
if (! $resource) {
$errors['target'] = ['The target must be a resource of this team.'];
}
if ($errors !== []) {
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$this->authorize('update', $resource);
if ($resource->tags->contains('id', $tag->id)) {
$message = "{$resource->name} already has the {$tag->name} tag.";
return response()->json(['message' => $message, 'errors' => ['target' => [$message]]], 422);
}
$resource->tags()->attach($tag->id);
return response()->json($this->canvas->link($tag, $resource), 201);
}
public function destroyLink(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
[$source, $target] = array_pad(explode('|', (string) $request->route('link_id'), 2), 2, '');
$tag = $this->findTag((int) $teamId, $source);
$resource = $this->canvas->resources((int) $teamId)->get($target);
if (! $tag || ! $resource || ! $resource->tags->contains('id', $tag->id)) {
return response()->json(['message' => 'Link not found.'], 404);
}
$this->authorize('update', $resource);
// The tag stays, also without resources. Delete it on the Tags page.
$resource->tags()->detach($tag->id);
return response()->json(['message' => 'Link removed.']);
}
private function findTag(int $teamId, ?string $nodeId): ?Tag
{
if (! str_starts_with((string) $nodeId, 'tag:')) {
return null;
}
return Tag::query()->where('team_id', $teamId)->where('uuid', substr($nodeId, strlen('tag:')))->first();
}
}
@@ -2,14 +2,24 @@
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\AvatarStorageService;
use Illuminate\Http\Response;
class ProfileAvatarController extends Controller
{
public function __invoke(AvatarStorageService $avatarStorage): Response
public function __invoke(AvatarStorageService $avatarStorage, ?int $user_id = null): Response
{
$contents = $avatarStorage->contents(auth()->user());
$user = auth()->user();
if ($user_id !== null && $user_id !== $user->id) {
$user = User::query()
->whereKey($user_id)
->whereHas('teams', fn ($query) => $query->whereIn('teams.id', auth()->user()->teams()->select('teams.id')))
->firstOrFail();
}
$contents = $avatarStorage->contents($user);
abort_if($contents === null, 404);
return response($contents, 200, [
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers;
use App\Services\TeamAvatarStorageService;
use Illuminate\Http\Response;
class TeamAvatarController extends Controller
{
public function __invoke(int $team_id, TeamAvatarStorageService $avatarStorage): Response
{
$team = auth()->user()->teams()->whereKey($team_id)->firstOrFail();
$contents = $avatarStorage->teamContents($team);
abort_if($contents === null, 404);
return response($contents, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=31536000, immutable',
]);
}
}