Files
coolify/app/Support/RuntimeLogTargets.php
Andras Bacsai 3bfeb14b7d style(logs): show deployment status dots like the service menu
Deployment options get the same status dot and Stopped caption as
services instead of colored badges, so the listbox no longer needs
labelClass.
2026-10-23 12:25:57 +02:00

447 lines
14 KiB
PHP

<?php
namespace App\Support;
use Illuminate\Support\Collection;
/**
* Groups running and stopped containers for the runtime logs page.
*
* A service is one stream. Every container of that service, on every server and
* from a rollout that left the previous container behind, is a source of that stream.
* Deployments are production, then preview pull requests from newest to oldest.
* Services are alphabetical.
*/
class RuntimeLogTargets
{
/**
* @param list<array{uuid: string, name: string, containers: list<array<string, mixed>>}> $servers
* @return array{
* deployments: list<array{value: string, label: string, dot: string, state?: string}>,
* services: list<array{value: string, label: string, dot: string, description?: string, state?: string}>,
* deployment: string,
* service: string,
* sources: list<array{server: string, container: string, label: string}>,
* streamKey: string,
* }
*/
public static function select(array $servers, string $deployment, string $service, bool $groupDeployments): array
{
$containers = self::containers($servers, $groupDeployments);
if ($containers === []) {
return self::emptySelection();
}
$deploymentIds = $groupDeployments ? self::sortedDeploymentIds($containers) : [];
$chosenDeployment = $groupDeployments
? self::pickDeployment($deploymentIds, self::normalizeDeployment($deployment))
: '0';
$visible = array_values(array_filter(
$containers,
fn (array $container): bool => $container['deployment'] === $chosenDeployment,
));
if ($visible === []) {
return self::emptySelection();
}
$groups = self::groupByService($visible);
$chosenKey = self::pickServiceKey($groups, $service);
$sources = self::sourcesFor($groups[$chosenKey]);
return [
'deployments' => $groupDeployments ? self::deploymentOptions($deploymentIds, $containers) : [],
'services' => self::serviceOptions($groups),
'deployment' => $chosenDeployment,
'service' => count($groups) > 1 ? $chosenKey : '',
'sources' => $sources,
'streamKey' => self::streamKey($sources),
];
}
/**
* @return array{
* deployments: list<empty>,
* services: list<empty>,
* deployment: string,
* service: string,
* sources: list<empty>,
* streamKey: string,
* }
*/
private static function emptySelection(): array
{
return [
'deployments' => [],
'services' => [],
'deployment' => '0',
'service' => '',
'sources' => [],
'streamKey' => '',
];
}
/**
* @param list<array{uuid: string, name: string, containers: list<array<string, mixed>>}> $servers
* @return list<array{name: string, key: string, state: string, deployment: string, server: string, serverName: string, serverIndex: int}>
*/
private static function containers(array $servers, bool $groupDeployments): array
{
$containers = [];
foreach (array_values($servers) as $index => $server) {
foreach ($server['containers'] ?? [] as $container) {
if (! is_array($container)) {
continue;
}
$name = (string) data_get($container, 'Names', '');
if (! ValidationPatterns::isValidContainerName($name)) {
continue;
}
$labels = self::labels($container);
$state = strtolower(trim((string) data_get($container, 'State', 'running')));
$containers[] = [
'name' => $name,
'key' => self::serviceKey($name, $labels),
'state' => $state !== '' ? $state : 'running',
'deployment' => self::deploymentId($labels, $groupDeployments),
'server' => (string) ($server['uuid'] ?? ''),
'serverName' => (string) ($server['name'] ?? ''),
'serverIndex' => $index,
];
}
}
return $containers;
}
/**
* @param array<string, mixed> $container
*/
private static function labels(array $container): Collection
{
$raw = $container['Labels'] ?? null;
if (is_array($raw)) {
return collect($raw);
}
if (! is_string($raw) || trim($raw) === '') {
return collect();
}
$parsed = format_docker_labels_to_json($raw);
return $parsed instanceof Collection ? $parsed : collect();
}
private static function serviceKey(string $containerName, Collection $labels): string
{
$compose = trim((string) $labels->get('com.docker.compose.service', ''));
if ($compose !== '') {
return $compose;
}
$name = trim((string) $labels->get('coolify.name', ''));
if ($name !== '') {
return $name;
}
$headline = str($containerName)->beforeLast('-')->headline()->toString();
return $headline !== '' ? $headline : $containerName;
}
private static function deploymentId(Collection $labels, bool $groupDeployments): string
{
if (! $groupDeployments) {
return '0';
}
return self::normalizeDeployment((string) $labels->get('coolify.pullRequestId', ''));
}
private static function normalizeDeployment(string $deployment): string
{
if ($deployment === '' || ! ctype_digit($deployment) || (int) $deployment === 0) {
return '0';
}
return (string) (int) $deployment;
}
/**
* @param list<array{deployment: string}> $containers
* @return list<string>
*/
private static function sortedDeploymentIds(array $containers): array
{
$ids = array_values(array_unique(array_column($containers, 'deployment')));
usort($ids, function (string $left, string $right): int {
if ($left === '0') {
return -1;
}
if ($right === '0') {
return 1;
}
return (int) $right <=> (int) $left;
});
return $ids;
}
/**
* @param list<string> $deploymentIds
*/
private static function pickDeployment(array $deploymentIds, string $preferred): string
{
if (in_array($preferred, $deploymentIds, true)) {
return $preferred;
}
if (in_array('0', $deploymentIds, true)) {
return '0';
}
return $deploymentIds[0] ?? '0';
}
/**
* Deployments show the same status dot and caption as services, for all their containers.
*
* @param list<string> $ids
* @param list<array{deployment: string, state: string}> $containers
* @return list<array{value: string, label: string, dot: string, state?: string}>
*/
private static function deploymentOptions(array $ids, array $containers): array
{
return array_map(function (string $id) use ($containers): array {
$states = array_column(array_filter(
$containers,
fn (array $container): bool => $container['deployment'] === $id,
), 'state');
return self::statusOption($id, $id === '0' ? 'Production' : 'PR #'.$id, $states);
}, $ids);
}
/**
* @param list<string> $states
* @return array{value: string, label: string, dot: string, state?: string}
*/
private static function statusOption(string $value, string $label, array $states): array
{
$option = ['value' => $value, 'label' => $label, 'dot' => self::serviceDot($states)];
$state = self::serviceStateLabel($states);
if ($state !== null) {
$option['state'] = $state;
}
return $option;
}
/**
* @param list<array{key: string}> $containers
* @return array<string, list<array{name: string, key: string, state: string, deployment: string, server: string, serverName: string, serverIndex: int}>>
*/
private static function groupByService(array $containers): array
{
$groups = [];
foreach ($containers as $container) {
$groups[$container['key']][] = $container;
}
uksort($groups, fn (string $left, string $right): int => strcasecmp($left, $right));
return $groups;
}
/**
* @param array<string, list<array{state: string}>> $groups
*/
private static function pickServiceKey(array $groups, string $service): string
{
if ($service !== '' && isset($groups[$service])) {
return $service;
}
foreach ($groups as $key => $containers) {
foreach ($containers as $container) {
if ($container['state'] === 'running') {
return $key;
}
}
}
return (string) array_key_first($groups);
}
/**
* @param array<string, list<array{state: string}>> $groups
* @return list<array{value: string, label: string, dot: string, description?: string, state?: string}>
*/
private static function serviceOptions(array $groups): array
{
$options = [];
foreach ($groups as $key => $containers) {
$option = self::statusOption($key, $key, array_column($containers, 'state'));
if (count($containers) > 1) {
$option['description'] = count($containers).' containers';
}
$options[] = $option;
}
return $options;
}
/**
* @param list<string> $states
*/
private static function serviceDot(array $states): string
{
$allRunning = true;
$anyLive = false;
foreach ($states as $state) {
if ($state !== 'running') {
$allRunning = false;
}
if (in_array($state, ['running', 'starting', 'restarting', 'degraded'], true)) {
$anyLive = true;
}
}
if ($allRunning) {
return 'success';
}
if (! $anyLive) {
return 'error';
}
return 'warning';
}
/**
* A running service needs no caption. Anything else is named: Stopped when
* nothing is running, the transitional state when that is the only problem,
* and Degraded when the containers disagree.
*
* @param list<string> $states
*/
private static function serviceStateLabel(array $states): ?string
{
$problems = [];
$anyLive = false;
foreach ($states as $state) {
$state = strtolower($state);
if (in_array($state, ['running', 'starting', 'restarting', 'degraded'], true)) {
$anyLive = true;
}
if ($state !== 'running') {
$problems[$state] = true;
}
}
if ($problems === []) {
return null;
}
$problemStates = array_keys($problems);
if ($problemStates === ['starting']) {
return 'Starting';
}
if ($problemStates === ['restarting']) {
return 'Restarting';
}
if ($problemStates === ['degraded']) {
return 'Degraded';
}
if (! $anyLive) {
return 'Stopped';
}
return 'Degraded';
}
/**
* @param list<array{name: string, server: string, serverName: string, serverIndex: int}> $containers
* @return list<array{server: string, container: string, label: string}>
*/
private static function sourcesFor(array $containers): array
{
usort($containers, function (array $left, array $right): int {
$byServer = $left['serverIndex'] <=> $right['serverIndex'];
return $byServer !== 0 ? $byServer : strcmp($left['name'], $right['name']);
});
$serverCounts = [];
foreach ($containers as $container) {
$serverCounts[$container['server']] = ($serverCounts[$container['server']] ?? 0) + 1;
}
$multipleServers = count($serverCounts) > 1;
$sources = [];
foreach ($containers as $container) {
$sources[] = [
'server' => $container['server'],
'container' => $container['name'],
'label' => self::sourceLabel($container, $multipleServers, $serverCounts[$container['server']], count($containers)),
];
}
return self::uniqueLabels($sources);
}
/**
* @param array{name: string, server: string, serverName: string} $container
*/
private static function sourceLabel(array $container, bool $multipleServers, int $onServer, int $total): string
{
if ($total < 2) {
return $container['name'];
}
$parts = [];
if ($multipleServers) {
$name = trim($container['serverName']);
$parts[] = $name !== '' ? $name : $container['server'];
}
if ($onServer > 1 || ! $multipleServers) {
$parts[] = $container['name'];
}
return implode(' ', $parts);
}
/**
* @param list<array{server: string, container: string, label: string}> $sources
* @return list<array{server: string, container: string, label: string}>
*/
private static function uniqueLabels(array $sources): array
{
$counts = array_count_values(array_column($sources, 'label'));
foreach ($sources as $index => $source) {
if ($counts[$source['label']] < 2) {
continue;
}
if (! str_contains($source['label'], $source['container'])) {
$sources[$index]['label'] = trim($source['label'].' '.$source['container']);
}
}
$counts = array_count_values(array_column($sources, 'label'));
foreach ($sources as $index => $source) {
if (($counts[$source['label']] ?? 0) < 2) {
continue;
}
$sources[$index]['label'] = $source['label'].' '.substr($source['server'], 0, 8);
}
return $sources;
}
/**
* @param list<array{server: string, container: string}> $sources
*/
private static function streamKey(array $sources): string
{
return implode('|', array_map(
fn (array $source): string => $source['server'].':'.$source['container'],
$sources,
));
}
}