feat(logs): Add loading indicator to download all logs buttons (#7847)
This commit is contained in:
commit
006e787e2a
5 changed files with 143 additions and 28 deletions
|
|
@ -121,6 +121,25 @@ public function copyLogs(): string
|
|||
return sanitizeLogsForExport($logs);
|
||||
}
|
||||
|
||||
public function downloadAllLogs(): string
|
||||
{
|
||||
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
|
||||
->map(function ($line) {
|
||||
$prefix = '';
|
||||
if ($line['hidden']) {
|
||||
$prefix = '[DEBUG] ';
|
||||
}
|
||||
if (isset($line['command']) && $line['command']) {
|
||||
$prefix .= '[CMD]: ';
|
||||
}
|
||||
|
||||
return $line['timestamp'].' '.$prefix.trim($line['line']);
|
||||
})
|
||||
->join("\n");
|
||||
|
||||
return sanitizeLogsForExport($logs);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.deployment.show');
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ class GetLogs extends Component
|
|||
{
|
||||
public const MAX_LOG_LINES = 50000;
|
||||
|
||||
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
public string $outputs = '';
|
||||
|
||||
public string $errors = '';
|
||||
|
|
@ -164,10 +166,12 @@ public function getLogs($refresh = false)
|
|||
}
|
||||
// Collect new logs into temporary variable first to prevent flickering
|
||||
// (avoids clearing output before new data is ready)
|
||||
$newOutputs = '';
|
||||
Process::run($sshCommand, function (string $type, string $output) use (&$newOutputs) {
|
||||
$newOutputs .= removeAnsiColors($output);
|
||||
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
|
||||
$logChunks = [];
|
||||
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
|
||||
$logChunks[] = removeAnsiColors($output);
|
||||
});
|
||||
$newOutputs = implode('', $logChunks);
|
||||
|
||||
if ($this->showTimeStamps) {
|
||||
$newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) {
|
||||
|
|
@ -215,11 +219,40 @@ public function downloadAllLogs(): string
|
|||
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
|
||||
$allLogs = '';
|
||||
Process::run($sshCommand, function (string $type, string $output) use (&$allLogs) {
|
||||
$allLogs .= removeAnsiColors($output);
|
||||
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
|
||||
// Enforce 50MB size limit to prevent memory exhaustion from large logs
|
||||
$logChunks = [];
|
||||
$accumulatedBytes = 0;
|
||||
$truncated = false;
|
||||
|
||||
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
|
||||
if ($truncated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$output = removeAnsiColors($output);
|
||||
$outputBytes = strlen($output);
|
||||
|
||||
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
|
||||
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
|
||||
if ($remaining > 0) {
|
||||
$logChunks[] = substr($output, 0, $remaining);
|
||||
}
|
||||
$truncated = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$logChunks[] = $output;
|
||||
$accumulatedBytes += $outputBytes;
|
||||
});
|
||||
|
||||
$allLogs = implode('', $logChunks);
|
||||
|
||||
if ($truncated) {
|
||||
$allLogs .= "\n\n[... Output truncated at 50MB limit ...]";
|
||||
}
|
||||
|
||||
if ($this->showTimeStamps) {
|
||||
$allLogs = str($allLogs)->split('/\n/')->sort(function ($a, $b) {
|
||||
$a = explode(' ', $a);
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
|
|||
throw new \RuntimeException($errorMessage, $exitCode);
|
||||
}
|
||||
|
||||
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection
|
||||
function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection
|
||||
{
|
||||
if (is_null($application_deployment_queue)) {
|
||||
return collect([]);
|
||||
|
|
@ -216,7 +216,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
|
|||
|
||||
$seenCommands = collect();
|
||||
$formatted = collect($decoded);
|
||||
if (! $is_debug_enabled) {
|
||||
if (! $is_debug_enabled && ! $includeAll) {
|
||||
$formatted = $formatted->filter(fn ($i) => $i['hidden'] === false ?? false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@
|
|||
class="flex flex-col w-full bg-white dark:text-white dark:bg-coolgray-100 dark:border-coolgray-300"
|
||||
:class="fullscreen ? 'h-full' : 'border border-dotted rounded-sm'">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
|
||||
class="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
@if (data_get($application_deployment_queue, 'status') === 'in_progress')
|
||||
<div class="flex items-center gap-1">
|
||||
|
|
@ -190,7 +190,7 @@ class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-co
|
|||
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 flex-1">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
|
|
@ -208,8 +208,9 @@ class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-6
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
x-on:click="
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
x-on:click="
|
||||
$wire.copyLogs().then(logs => {
|
||||
navigator.clipboard.writeText(logs);
|
||||
Livewire.dispatch('success', ['Logs copied to clipboard.']);
|
||||
|
|
@ -223,14 +224,61 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
|||
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
</button>
|
||||
<button x-on:click="downloadLogs()" title="Download Logs"
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative">
|
||||
<button x-on:click="downloadMenuOpen = !downloadMenuOpen" title="Download Logs"
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-show="downloadMenuOpen" x-on:click.away="downloadMenuOpen = false"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute right-0 z-50 mt-2 w-max origin-top-right rounded-md bg-white dark:bg-coolgray-200 shadow-lg ring-1 ring-neutral-200 dark:ring-coolgray-300 focus:outline-none">
|
||||
<div class="py-1">
|
||||
<button x-on:click="downloadLogs(); downloadMenuOpen = false"
|
||||
class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">
|
||||
Download displayed logs
|
||||
</button>
|
||||
<button x-on:click="
|
||||
downloadingAllLogs = true;
|
||||
$wire.downloadAllLogs().then(logs => {
|
||||
if (!logs) return;
|
||||
const blob = new Blob([logs], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
|
||||
a.download = 'deployment-' + deploymentId + '-all-logs-' + timestamp + '.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
Livewire.dispatch('success', ['All logs downloaded.']);
|
||||
}).finally(() => {
|
||||
downloadingAllLogs = false;
|
||||
downloadMenuOpen = false;
|
||||
});
|
||||
"
|
||||
:disabled="downloadingAllLogs"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': downloadingAllLogs }"
|
||||
class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">
|
||||
<span x-show="!downloadingAllLogs">Download all logs</span>
|
||||
<span x-show="downloadingAllLogs" class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Downloading...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button title="Toggle Timestamps" x-on:click="showTimestamps = !showTimestamps"
|
||||
:class="showTimestamps ? '!text-warning' : ''"
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
|
|
@ -276,6 +324,7 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
|||
d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logsContainer"
|
||||
|
|
|
|||
|
|
@ -246,19 +246,19 @@
|
|||
<div class="flex flex-col dark:text-white dark:border-coolgray-300 border-neutral-200"
|
||||
:class="fullscreen ? 'h-full w-full bg-white dark:bg-coolgray-100' : 'bg-white dark:bg-coolgray-100 border border-solid rounded-sm'">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
|
||||
class="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<form wire:submit="getLogs(true)" class="relative flex items-center">
|
||||
<span
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none">Lines:</span>
|
||||
<input type="number" wire:model="numberOfLines" placeholder="100" min="1" max="50000"
|
||||
title="Number of Lines (max 50,000)" {{ $streamLogs ? 'readonly' : '' }}
|
||||
class="input input-sm w-32 pl-11 text-center dark:bg-coolgray-300" />
|
||||
class="input input-sm w-32 pl-11 dark:bg-coolgray-300" />
|
||||
</form>
|
||||
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 flex-1">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
|
|
@ -276,7 +276,8 @@ class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-6
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button wire:click="getLogs(true)" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button wire:click="getLogs(true)" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
|
|
@ -316,7 +317,7 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
|||
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-data="{ downloadMenuOpen: false }" class="relative">
|
||||
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative">
|
||||
<button x-on:click="downloadMenuOpen = !downloadMenuOpen" title="Download Logs"
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
|
|
@ -332,13 +333,14 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
|||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-md bg-white dark:bg-coolgray-200 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
class="absolute right-0 z-50 mt-2 w-max origin-top-right rounded-md bg-white dark:bg-coolgray-200 shadow-lg ring-1 ring-neutral-200 dark:ring-coolgray-300 focus:outline-none">
|
||||
<div class="py-1">
|
||||
<button x-on:click="downloadLogs(); downloadMenuOpen = false"
|
||||
class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">
|
||||
Download displayed logs
|
||||
</button>
|
||||
<button x-on:click="
|
||||
downloadingAllLogs = true;
|
||||
$wire.downloadAllLogs().then(logs => {
|
||||
if (!logs) return;
|
||||
const blob = new Blob([logs], { type: 'text/plain' });
|
||||
|
|
@ -350,11 +352,22 @@ class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200
|
|||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
Livewire.dispatch('success', ['All logs downloaded.']);
|
||||
}).finally(() => {
|
||||
downloadingAllLogs = false;
|
||||
downloadMenuOpen = false;
|
||||
});
|
||||
downloadMenuOpen = false;
|
||||
"
|
||||
:disabled="downloadingAllLogs"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': downloadingAllLogs }"
|
||||
class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">
|
||||
Download all logs
|
||||
<span x-show="!downloadingAllLogs">Download all logs</span>
|
||||
<span x-show="downloadingAllLogs" class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Downloading...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -402,6 +415,7 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
|||
stroke-width="2" d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logsContainer" @scroll="handleScroll"
|
||||
|
|
|
|||
Loading…
Reference in a new issue