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:
Andras Bacsai
2026-09-13 15:25:20 +02:00
parent 6f58ad4b97
commit 7d11bc92f7
15 changed files with 186 additions and 39 deletions
+9
View File
@@ -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.
-3
View File
@@ -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',
+2
View File
@@ -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
+15 -6
View File
@@ -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];
+3 -1
View File
@@ -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;
}
}
+11 -4
View File
@@ -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,
+44 -1
View File
@@ -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();