mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-24 23:40:16 -05:00
feat(logs): accept all for log lines and keep -1
API log endpoints and the GetLogs UI now treat `all` as unbounded output, with `-1` remaining as a compatibility alias. MCP still falls back to a positive default. Traefik version checks run from CheckForUpdatesJob instead of a weekly schedule.
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Lessons
|
||||
|
||||
## Check prior fixes before changing a repeated symptom
|
||||
- When a reported regression matches a recent fix, inspect that fix and reproduce why it no longer works before adding another workaround.
|
||||
- Do not claim a redirect or lifecycle root cause from an effects assertion alone. Prove the reported HTTP or browser failure first.
|
||||
- Preserve SPA navigation when it is a product requirement. Do not replace it with a full-page redirect to mask a deletion race; fix the ordering or state race instead.
|
||||
|
||||
## Confirm which surface becomes the modal
|
||||
- When a user wants two settings pages replaced by a modal, identify the parent page that owns the trigger and confirm that the complete child settings view moves into that modal.
|
||||
- Do not make one child page a modal inside the other child page when the user wants both child URLs removed.
|
||||
@@ -82,3 +87,7 @@
|
||||
|
||||
## Keep modal actions in the footer
|
||||
- When a modal has a large editable body, put preview, validation, and save controls in a fixed footer. Keep the title bar for the title and close action.
|
||||
|
||||
## Prefer named API values over numeric sentinels
|
||||
- When an API option means an unbounded or special mode, expose a clear named value such as `all`.
|
||||
- Keep an existing numeric sentinel such as `-1` only as a compatibility alias unless the user requests a breaking change.
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Jobs\ApiTokenExpirationWarningJob;
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CheckMissingDatabaseBackupsJob;
|
||||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||
use App\Jobs\CleanupStaleMultiplexedConnections;
|
||||
@@ -91,8 +90,6 @@ class Kernel extends ConsoleKernel
|
||||
|
||||
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer();
|
||||
|
||||
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
|
||||
|
||||
$this->scheduleInstance->command('cleanup:database --yes')->daily();
|
||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||
|
||||
|
||||
@@ -2410,13 +2410,12 @@ class ApplicationsController extends Controller
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs.',
|
||||
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
|
||||
required: false,
|
||||
schema: new OA\Schema(
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
default: 100,
|
||||
)
|
||||
schema: new OA\Schema(oneOf: [
|
||||
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
|
||||
new OA\Schema(type: 'string', enum: ['all']),
|
||||
])
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'show_timestamps',
|
||||
|
||||
@@ -2433,13 +2433,12 @@ class DatabasesController extends Controller
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs.',
|
||||
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
|
||||
required: false,
|
||||
schema: new OA\Schema(
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
default: 100,
|
||||
)
|
||||
schema: new OA\Schema(oneOf: [
|
||||
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
|
||||
new OA\Schema(type: 'string', enum: ['all']),
|
||||
])
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'show_timestamps',
|
||||
|
||||
@@ -402,9 +402,12 @@ class ServiceApplicationsController extends Controller
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs.',
|
||||
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)
|
||||
schema: new OA\Schema(oneOf: [
|
||||
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
|
||||
new OA\Schema(type: 'string', enum: ['all']),
|
||||
])
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
@@ -451,7 +454,16 @@ class ServiceApplicationsController extends Controller
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
|
||||
required: false,
|
||||
schema: new OA\Schema(oneOf: [
|
||||
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
|
||||
new OA\Schema(type: 'string', enum: ['all']),
|
||||
]),
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
|
||||
@@ -288,7 +288,16 @@ class ServiceDatabasesController extends Controller
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
|
||||
required: false,
|
||||
schema: new OA\Schema(oneOf: [
|
||||
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
|
||||
new OA\Schema(type: 'string', enum: ['all']),
|
||||
]),
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])),
|
||||
|
||||
@@ -869,13 +869,12 @@ class ServicesController extends Controller
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs.',
|
||||
description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.',
|
||||
required: false,
|
||||
schema: new OA\Schema(
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
default: 100,
|
||||
)
|
||||
schema: new OA\Schema(oneOf: [
|
||||
new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000),
|
||||
new OA\Schema(type: 'string', enum: ['all']),
|
||||
])
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'show_timestamps',
|
||||
|
||||
@@ -73,6 +73,8 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// Invalidate cache to ensure fresh data is loaded
|
||||
invalidate_versions_cache();
|
||||
|
||||
CheckTraefikVersionJob::dispatch();
|
||||
|
||||
// Only mark new version available if Coolify version actually increased
|
||||
if (version_compare($latest_version, $current_version, '>')) {
|
||||
// New version available
|
||||
|
||||
@@ -131,6 +131,12 @@ class GetLogs extends Component
|
||||
$this->streamLogs = ! $this->streamLogs;
|
||||
}
|
||||
|
||||
public function showAllLogs(): void
|
||||
{
|
||||
$this->numberOfLines = -1;
|
||||
$this->getLogs(true);
|
||||
}
|
||||
|
||||
public function getLogs($refresh = false)
|
||||
{
|
||||
if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) {
|
||||
@@ -149,22 +155,25 @@ class GetLogs extends Component
|
||||
if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) {
|
||||
return;
|
||||
}
|
||||
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
|
||||
$logTail = $this->numberOfLines === -1 ? 'all' : $this->numberOfLines;
|
||||
if ($logTail !== 'all' && ($logTail <= 0 || is_null($logTail))) {
|
||||
$this->numberOfLines = 1000;
|
||||
$logTail = $this->numberOfLines;
|
||||
}
|
||||
if ($this->numberOfLines > self::MAX_LOG_LINES) {
|
||||
if ($logTail !== 'all' && $logTail > self::MAX_LOG_LINES) {
|
||||
$this->numberOfLines = self::MAX_LOG_LINES;
|
||||
$logTail = $this->numberOfLines;
|
||||
}
|
||||
if ($this->container) {
|
||||
if ($this->showTimeStamps) {
|
||||
if ($this->server->isSwarm()) {
|
||||
$command = "docker service logs -n {$this->numberOfLines} -t {$this->container}";
|
||||
$command = "docker service logs -n {$logTail} -t {$this->container}";
|
||||
if ($this->server->isNonRoot()) {
|
||||
$command = parseCommandsByLineForSudo(collect($command), $this->server);
|
||||
$command = $command[0];
|
||||
}
|
||||
} else {
|
||||
$command = "docker logs -n {$this->numberOfLines} -t {$this->container}";
|
||||
$command = "docker logs -n {$logTail} -t {$this->container}";
|
||||
if ($this->server->isNonRoot()) {
|
||||
$command = parseCommandsByLineForSudo(collect($command), $this->server);
|
||||
$command = $command[0];
|
||||
@@ -172,13 +181,13 @@ class GetLogs extends Component
|
||||
}
|
||||
} else {
|
||||
if ($this->server->isSwarm()) {
|
||||
$command = "docker service logs -n {$this->numberOfLines} {$this->container}";
|
||||
$command = "docker service logs -n {$logTail} {$this->container}";
|
||||
if ($this->server->isNonRoot()) {
|
||||
$command = parseCommandsByLineForSudo(collect($command), $this->server);
|
||||
$command = $command[0];
|
||||
}
|
||||
} else {
|
||||
$command = "docker logs -n {$this->numberOfLines} {$this->container}";
|
||||
$command = "docker logs -n {$logTail} {$this->container}";
|
||||
if ($this->server->isNonRoot()) {
|
||||
$command = parseCommandsByLineForSudo(collect($command), $this->server);
|
||||
$command = $command[0];
|
||||
|
||||
@@ -98,6 +98,8 @@ trait ResolvesResource
|
||||
*/
|
||||
protected function normalizeMcpLogLines(mixed $lines): int
|
||||
{
|
||||
return normalizeLogLines($lines, default: 100, max: 500);
|
||||
$lines = normalizeLogLines($lines, default: 100, max: 500);
|
||||
|
||||
return is_int($lines) && $lines > 0 ? $lines : 100;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1540,10 +1540,17 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int
|
||||
function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int|string
|
||||
{
|
||||
if ($lines === 'all') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
$lines = filter_var($lines, FILTER_VALIDATE_INT);
|
||||
if ($lines === false || $lines <= 0) {
|
||||
if ($lines === -1) {
|
||||
return 'all';
|
||||
}
|
||||
if ($lines === false || $lines < -1) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
@@ -1555,7 +1562,7 @@ function parseLogTimestampFlag(mixed $showTimestamps): bool
|
||||
return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
|
||||
}
|
||||
|
||||
function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
|
||||
function buildContainerLogsCommand(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string
|
||||
{
|
||||
$command = "docker logs -n {$lines}";
|
||||
if ($server->isSwarm()) {
|
||||
@@ -1569,7 +1576,7 @@ function buildContainerLogsCommand(Server $server, string $container_id, int $li
|
||||
return "{$command} ".escapeshellarg($container_id).' 2>&1';
|
||||
}
|
||||
|
||||
function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
|
||||
function getContainerLogs(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string
|
||||
{
|
||||
$output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server);
|
||||
$output = removeAnsiColors($output);
|
||||
|
||||
@@ -484,9 +484,11 @@
|
||||
<div class="logs-viewer-meta">
|
||||
<form wire:submit="getLogs(true)" class="logs-viewer-lines">
|
||||
<span class="logs-viewer-lines-label">Lines</span>
|
||||
<input type="number" wire:model="numberOfLines" placeholder="100" min="1" max="50000"
|
||||
title="Number of Lines (max 50,000)" {{ $streamLogs ? 'readonly' : '' }}
|
||||
<input type="number" wire:model="numberOfLines" placeholder="100" min="-1" max="50000"
|
||||
title="Number of lines (max 50,000; use -1 for all)" {{ $streamLogs ? 'readonly' : '' }}
|
||||
class="input logs-viewer-lines-input" />
|
||||
<button type="button" wire:click="showAllLogs" title="Show all logs"
|
||||
class="runtime-log-icon-button" {{ $streamLogs ? 'disabled' : '' }}>All</button>
|
||||
</form>
|
||||
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
|
||||
class="text-xs text-gray-500 whitespace-nowrap dark:text-gray-400"></span>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('checks installed Traefik versions after refreshing available versions', function () {
|
||||
Bus::fake();
|
||||
Http::fake([
|
||||
'*' => Http::response([
|
||||
'coolify' => ['v4' => ['version' => '4.0.10']],
|
||||
'traefik' => ['v3.7' => '3.7.13'],
|
||||
]),
|
||||
]);
|
||||
File::shouldReceive('exists')->andReturn(false);
|
||||
File::shouldReceive('put')->once();
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
config([
|
||||
'app.env' => 'production',
|
||||
'constants.coolify.self_hosted' => true,
|
||||
'constants.coolify.version' => '4.0.10',
|
||||
]);
|
||||
|
||||
(new CheckForUpdatesJob)->handle();
|
||||
|
||||
Bus::assertDispatched(CheckTraefikVersionJob::class);
|
||||
});
|
||||
@@ -74,6 +74,29 @@ describe('GetLogs locked properties', function () {
|
||||
});
|
||||
|
||||
describe('GetLogs Livewire action validation', function () {
|
||||
test('getLogs requests all logs when the line count is minus one', function () {
|
||||
$this->server->settings->fill([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
])->save();
|
||||
$server = Server::with('settings')->findOrFail($this->server->id);
|
||||
|
||||
Process::fake(['*' => Process::result(output: 'all logs')]);
|
||||
|
||||
Livewire::test(GetLogs::class, [
|
||||
'server' => $server,
|
||||
'resource' => $this->application,
|
||||
'container' => 'test-container',
|
||||
])
|
||||
->assertSee('All')
|
||||
->assertSeeHtml('title="Show all logs"')
|
||||
->call('showAllLogs')
|
||||
->assertSet('numberOfLines', -1);
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n all'));
|
||||
});
|
||||
|
||||
test('getLogs marks ANSI-colored output truncated based on raw bytes', function () {
|
||||
$this->server->settings->fill([
|
||||
'is_reachable' => true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Mcp\Concerns\ResolvesResource;
|
||||
use App\Models\Server;
|
||||
|
||||
it('normalizes requested log line counts', function () {
|
||||
@@ -12,12 +13,44 @@ it('normalizes requested log line counts', function () {
|
||||
expect(normalizeLogLines(null))->toBe(100)
|
||||
->and(normalizeLogLines(''))->toBe(100)
|
||||
->and(normalizeLogLines('abc'))->toBe(100)
|
||||
->and(normalizeLogLines('0'))->toBe(100)
|
||||
->and(normalizeLogLines('all'))->toBe('all')
|
||||
->and(normalizeLogLines('ALL'))->toBe(100)
|
||||
->and(normalizeLogLines('-1'))->toBe('all')
|
||||
->and(normalizeLogLines('0'))->toBe(0)
|
||||
->and(normalizeLogLines('-5'))->toBe(100)
|
||||
->and(normalizeLogLines('50'))->toBe(50)
|
||||
->and(normalizeLogLines('50000'))->toBe(10000);
|
||||
});
|
||||
|
||||
it('keeps MCP log requests bounded', function () {
|
||||
$normalizer = new class
|
||||
{
|
||||
use ResolvesResource;
|
||||
|
||||
public function normalize(mixed $lines): int
|
||||
{
|
||||
return $this->normalizeMcpLogLines($lines);
|
||||
}
|
||||
};
|
||||
|
||||
expect($normalizer->normalize('all'))->toBe(100)
|
||||
->and($normalizer->normalize('-1'))->toBe(100)
|
||||
->and($normalizer->normalize('0'))->toBe(100)
|
||||
->and($normalizer->normalize('501'))->toBe(500);
|
||||
});
|
||||
|
||||
it('documents the named all logs option on every REST log endpoint', function (string $controller) {
|
||||
$source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
|
||||
|
||||
expect($source)->toContain('Use `all` to return all logs. `-1` remains available as a compatibility alias.');
|
||||
})->with([
|
||||
'applications' => 'ApplicationsController',
|
||||
'databases' => 'DatabasesController',
|
||||
'services' => 'ServicesController',
|
||||
'service applications' => 'ServiceApplicationsController',
|
||||
'service databases' => 'ServiceDatabasesController',
|
||||
]);
|
||||
|
||||
it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) {
|
||||
$source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php");
|
||||
|
||||
@@ -63,6 +96,16 @@ it('builds docker log commands with options before an escaped container id', fun
|
||||
->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1");
|
||||
});
|
||||
|
||||
it('builds docker log commands for all and zero lines', function () {
|
||||
$server = new Server;
|
||||
$server->settings = ['is_swarm_manager' => false];
|
||||
|
||||
expect(buildContainerLogsCommand($server, 'container-1', 'all'))
|
||||
->toBe("docker logs -n all 'container-1' 2>&1")
|
||||
->and(buildContainerLogsCommand($server, 'container-1', 0))
|
||||
->toBe("docker logs -n 0 'container-1' 2>&1");
|
||||
});
|
||||
|
||||
it('builds swarm service log commands with options before an escaped service id', function () {
|
||||
if (! function_exists('buildContainerLogsCommand')) {
|
||||
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
|
||||
|
||||
Reference in New Issue
Block a user