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
+96
View File
@@ -26,15 +26,111 @@ function service_logo_urls(mixed $logo): array
}
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\LocalFileVolume;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
/**
* The URL of a logo in public/svgs/resources. The file's modification time is the version, so
* browsers load a changed logo instead of their cached copy.
*/
function resource_logo_url(string $name): string
{
$path = "svgs/resources/{$name}.svg";
$modifiedAt = is_file(public_path($path)) ? filemtime(public_path($path)) : false;
return asset($path).($modifiedAt ? "?v={$modifiedAt}" : '');
}
/**
* The logo of a resource, the same one shown on the "New resource" page: the database engine,
* the service template, or how the application is built. Null when there is no specific logo.
*
* Applications with a domain also get the favicon URL of their first domain. The browser loads it
* and shows it in place of the logo when it loads. Coolify itself never requests it.
*
* @return array{src: string, dark: ?string, fallback: ?string, padded: bool, favicon: ?string}|null
*/
function resource_logo(Model $resource): ?array
{
$resourceLogo = function (string $name, bool $hasDarkVariant = false): array {
return [
'src' => resource_logo_url($name),
'dark' => $hasDarkVariant ? resource_logo_url("{$name}-dark") : null,
'fallback' => null,
'padded' => false,
'favicon' => null,
];
};
if ($resource instanceof Service) {
$templates = once(fn () => get_service_templates());
$type = filled($resource->service_type) ? $resource->service_type : str($resource->name)->beforeLast('-')->value();
$logo = data_get($templates->get(resolve_service_template_key($type, $templates)), 'logo');
if (! is_string($logo) || $logo === '' || basename($logo) === 'default.webp') {
return null;
}
$urls = service_logo_urls($logo);
return ['src' => $urls['logo'], 'dark' => null, 'fallback' => $urls['logo_cdn_url'], 'padded' => true, 'favicon' => null];
}
if ($resource instanceof Application) {
$logo = match (true) {
$resource->build_pack === 'dockerimage' => $resourceLogo('docker-image'),
$resource->build_pack === 'dockerfile' => $resourceLogo('dockerfile'),
$resource->build_pack === 'dockercompose' => $resourceLogo('docker-compose'),
$resource->source_type === GithubApp::class => $resourceLogo('github-app', true),
$resource->source_type === GitlabApp::class => $resourceLogo('gitlab-app'),
default => $resourceLogo('public-repo'),
};
return [...$logo, 'favicon' => resource_favicon_url($resource->fqdn)];
}
if (! method_exists($resource, 'type')) {
return null;
}
return match (str($resource->type())->after('standalone-')->value()) {
'postgresql' => $resourceLogo('postgres'),
'mysql' => $resourceLogo('mysql'),
'mariadb' => $resourceLogo('mariadb'),
'mongodb' => $resourceLogo('mongodb'),
'redis' => $resourceLogo('redis'),
'keydb' => $resourceLogo('keydb', true),
'dragonfly' => $resourceLogo('dragonfly', true),
'clickhouse' => $resourceLogo('clickhouse'),
'sqlite' => $resourceLogo('sqlite'),
default => null,
};
}
/**
* The favicon URL of the first domain in a comma-separated domain list, or null when there is no usable domain.
*/
function resource_favicon_url(?string $fqdn): ?string
{
$domain = str((string) $fqdn)->explode(',')->map(fn (string $url) => trim($url))->first(fn (string $url) => $url !== '');
$parts = parse_url((string) $domain);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = (string) ($parts['host'] ?? '');
if (! in_array($scheme, ['http', 'https'], true) || $host === '' || str_contains($host, '*')) {
return null;
}
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
return "{$scheme}://{$host}{$port}/favicon.ico";
}
function replaceRegex(?string $name = null)
{
return "/\\\${?{$name}[^}]*}?|\\\${$name}\w+/";
+23
View File
@@ -892,6 +892,10 @@ function profile_avatar_url(User $user): string
}
}
if (auth()->check() && $user->id !== auth()->id()) {
return route('team.member.avatar', ['user_id' => $user->id, 'v' => $user->updated_at->timestamp]);
}
return route('profile.avatar', ['v' => $user->updated_at->timestamp]);
}
@@ -910,6 +914,21 @@ function project_icon_url(Project $project): string
]);
}
function team_avatar_url(Team $team): string
{
if ($team->avatar_storage_type === 's3') {
$url = s3_image_url($team->avatar_s3_storage_id, $team->avatar_path, $team->updated_at->timestamp);
if ($url) {
return $url;
}
}
return route('team.avatar', [
'team_id' => $team->id,
'v' => $team->updated_at->timestamp,
]);
}
function s3_image_url(?int $storageId, ?string $path, int $version): ?string
{
if (! $storageId || blank($path)) {
@@ -4601,6 +4620,10 @@ function settingsSearchItems(iterable $groupedItems, array $routeParameters, arr
foreach ($groupedItems as $groupLabel => $groupItems) {
foreach ($groupItems as $item) {
if ($item['disabled'] ?? false) {
continue;
}
$href = route($item['route'], $routeParameters);
$add($item['label'], $groupLabel, $href, $item['navigate'] ?? true);