mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 02:15:51 +00:00
feat(ui): improve configuration changes modal
This commit is contained in:
10 files changed
+426
-46
No files matched your search
@@ -4,10 +4,13 @@ namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ApplicationConfigurationSnapshot
|
||||
{
|
||||
use SummarizesDiffText;
|
||||
|
||||
public const SCHEMA_VERSION = 1;
|
||||
|
||||
public function __construct(protected Application $application) {}
|
||||
@@ -115,12 +118,14 @@ class ApplicationConfigurationSnapshot
|
||||
$this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'),
|
||||
$this->item('install_command', 'Install command', $this->application->install_command, 'build'),
|
||||
$this->item('build_command', 'Build command', $this->application->build_command, 'build'),
|
||||
$this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)),
|
||||
$this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile), displayFull: $this->application->dockerfile),
|
||||
$this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'),
|
||||
$this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'),
|
||||
$this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'),
|
||||
$this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)),
|
||||
$this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)),
|
||||
// The generated docker_compose is intentionally excluded: it is re-rendered
|
||||
// from git on every parse (resolved env, generated labels, deployment context),
|
||||
// so comparing it would flag a permanent change for git-based compose apps.
|
||||
$this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'),
|
||||
$this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'),
|
||||
$this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'),
|
||||
$this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'),
|
||||
@@ -162,9 +167,10 @@ class ApplicationConfigurationSnapshot
|
||||
{
|
||||
return [
|
||||
$this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'),
|
||||
$this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'),
|
||||
$this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
|
||||
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)),
|
||||
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)),
|
||||
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'),
|
||||
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration),
|
||||
$this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'),
|
||||
$this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'),
|
||||
$this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'),
|
||||
@@ -234,6 +240,7 @@ class ApplicationConfigurationSnapshot
|
||||
private function environmentItem(EnvironmentVariable $environmentVariable): array
|
||||
{
|
||||
$impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy';
|
||||
$locked = (bool) $environmentVariable->is_shown_once;
|
||||
$compareValue = [
|
||||
'value_hash' => $this->sensitiveHash($environmentVariable->value),
|
||||
'is_multiline' => $environmentVariable->is_multiline,
|
||||
@@ -242,20 +249,62 @@ class ApplicationConfigurationSnapshot
|
||||
'is_runtime' => $environmentVariable->is_runtime,
|
||||
];
|
||||
|
||||
// Locked (is_shown_once) variables are always redacted and never store a value.
|
||||
if ($locked) {
|
||||
return $this->item(
|
||||
key: (string) $environmentVariable->key,
|
||||
label: (string) $environmentVariable->key,
|
||||
value: $compareValue,
|
||||
impact: $impact,
|
||||
sensitive: true,
|
||||
displayValue: $this->environmentDisplayValue($environmentVariable),
|
||||
);
|
||||
}
|
||||
|
||||
// Unlocked variables expose their value so owners/admins can see the change.
|
||||
// The compare value is pre-hashed (identical formula to the locked branch) so
|
||||
// change detection stays stable and never carries the raw value; members are
|
||||
// redacted at render time in ConfigurationChecker; the column is encrypted at rest.
|
||||
// The value and each scope flag are rendered as their own line and diffed by line,
|
||||
// so a change to one or more attributes shows exactly what changed (one line each).
|
||||
$value = (string) $environmentVariable->value;
|
||||
|
||||
return $this->item(
|
||||
key: (string) $environmentVariable->key,
|
||||
label: (string) $environmentVariable->key,
|
||||
value: $compareValue,
|
||||
value: $this->sensitiveHash($this->normalizeValue($compareValue)),
|
||||
impact: $impact,
|
||||
sensitive: true,
|
||||
displayValue: $this->environmentDisplayValue($environmentVariable),
|
||||
sensitive: false,
|
||||
displayValue: $this->summarizeText($value),
|
||||
displayFull: $this->environmentLines($environmentVariable),
|
||||
diffMode: 'lines',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per attribute so the line diff surfaces exactly which value/flags changed.
|
||||
*/
|
||||
private function environmentLines(EnvironmentVariable $environmentVariable): string
|
||||
{
|
||||
$lines = collect();
|
||||
|
||||
$value = (string) $environmentVariable->value;
|
||||
if (filled($value)) {
|
||||
$lines->push($value);
|
||||
}
|
||||
|
||||
$lines->push('Available at build: '.($environmentVariable->is_buildtime ? 'enabled' : 'disabled'));
|
||||
$lines->push('Available at runtime: '.($environmentVariable->is_runtime ? 'enabled' : 'disabled'));
|
||||
$lines->push('Multiline: '.($environmentVariable->is_multiline ? 'enabled' : 'disabled'));
|
||||
$lines->push('Literal: '.($environmentVariable->is_literal ? 'enabled' : 'disabled'));
|
||||
|
||||
return $lines->implode("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array
|
||||
private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null, ?string $displayFull = null, string $diffMode = 'default'): array
|
||||
{
|
||||
$normalizedValue = $this->normalizeValue($value);
|
||||
|
||||
@@ -264,21 +313,28 @@ class ApplicationConfigurationSnapshot
|
||||
'label' => $label,
|
||||
'impact' => $impact,
|
||||
'sensitive' => $sensitive,
|
||||
'diff_mode' => $diffMode,
|
||||
'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue,
|
||||
'display_value' => $displayValue ?? $this->displayValue($normalizedValue),
|
||||
'display_full' => $sensitive ? null : $this->expandableText($displayFull ?? $this->stringifyValue($normalizedValue)),
|
||||
];
|
||||
}
|
||||
|
||||
private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string
|
||||
{
|
||||
$flags = collect([
|
||||
$flags = $this->environmentFlags($environmentVariable);
|
||||
|
||||
return $flags ? "Hidden ({$flags})" : 'Hidden';
|
||||
}
|
||||
|
||||
private function environmentFlags(EnvironmentVariable $environmentVariable): string
|
||||
{
|
||||
return collect([
|
||||
$environmentVariable->is_buildtime ? 'build-time' : null,
|
||||
$environmentVariable->is_runtime ? 'runtime' : null,
|
||||
$environmentVariable->is_multiline ? 'multiline' : null,
|
||||
$environmentVariable->is_literal ? 'literal' : null,
|
||||
])->filter()->implode(', ');
|
||||
|
||||
return $flags ? "Hidden ({$flags})" : 'Hidden';
|
||||
}
|
||||
|
||||
private function sensitiveHash(mixed $value): string
|
||||
@@ -320,6 +376,58 @@ class ApplicationConfigurationSnapshot
|
||||
return $this->summarizeText((string) $value);
|
||||
}
|
||||
|
||||
private function stringifyValue(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || is_bool($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return json_encode($value, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodedComposeDomains(): ?array
|
||||
{
|
||||
if (blank($this->application->docker_compose_domains)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $this->application->docker_compose_domains, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function composeDomainsText(): ?string
|
||||
{
|
||||
$decoded = $this->decodedComposeDomains();
|
||||
|
||||
if (blank($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collect($decoded)
|
||||
->map(fn ($value, $service): string => $service.': '.(filled(data_get($value, 'domain')) ? data_get($value, 'domain') : '-'))
|
||||
->sort()
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
private function decodeCustomLabels(?string $value): ?string
|
||||
{
|
||||
if (blank($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
|
||||
return $decoded === false ? $value : $decoded;
|
||||
}
|
||||
|
||||
private function summarizeText(?string $value): string
|
||||
{
|
||||
if (blank($value)) {
|
||||
@@ -333,6 +441,6 @@ class ApplicationConfigurationSnapshot
|
||||
return str($value)->limit(80)." ({$lines} lines)";
|
||||
}
|
||||
|
||||
return str($value)->limit(120)->value();
|
||||
return str($value)->limit(self::SINGLE_LINE_LIMIT)->value();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\DeploymentConfiguration\Concerns;
|
||||
|
||||
trait SummarizesDiffText
|
||||
{
|
||||
/**
|
||||
* Maximum length of a single-line value before it is truncated/considered
|
||||
* worth expanding. Kept as one constant so the snapshot summary and the
|
||||
* differ's expand decision never drift apart.
|
||||
*/
|
||||
private const SINGLE_LINE_LIMIT = 120;
|
||||
|
||||
/**
|
||||
* Returns the value only when it is worth expanding (multi-line or longer
|
||||
* than the single-line truncation limit). Otherwise null.
|
||||
*/
|
||||
private function expandableText(?string $value): ?string
|
||||
{
|
||||
if (blank($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
if (str_contains($value, "\n") || mb_strlen($value) > self::SINGLE_LINE_LIMIT) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ConfigurationDiff
|
||||
{
|
||||
/**
|
||||
@@ -81,20 +79,6 @@ class ConfigurationDiff
|
||||
return $this->changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{label: string, changes: array<int, array<string, mixed>>}>
|
||||
*/
|
||||
public function groupedChanges(): array
|
||||
{
|
||||
return collect($this->changes)
|
||||
->groupBy('section')
|
||||
->map(fn (Collection $changes): array => [
|
||||
'label' => (string) data_get($changes->first(), 'section_label', str((string) $changes->keys()->first())->headline()),
|
||||
'changes' => $changes->values()->all(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array<int, array<string, mixed>>}
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,21 @@
|
||||
|
||||
namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText;
|
||||
|
||||
class ConfigurationDiffer
|
||||
{
|
||||
use SummarizesDiffText;
|
||||
|
||||
/**
|
||||
* Keys that must never be reported as changes. The generated docker_compose
|
||||
* is re-rendered from git on every parse, so legacy snapshots that still
|
||||
* contain it would otherwise flag a permanent change after it was dropped.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const IGNORED_KEYS = ['build.docker_compose'];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $previousSnapshot
|
||||
* @param array<string, mixed> $currentSnapshot
|
||||
@@ -16,6 +29,10 @@ class ConfigurationDiffer
|
||||
$changes = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (in_array($key, self::IGNORED_KEYS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$previous = $previousItems[$key] ?? null;
|
||||
$current = $currentItems[$key] ?? null;
|
||||
|
||||
@@ -27,6 +44,37 @@ class ConfigurationDiffer
|
||||
$sensitive = (bool) data_get($item, 'sensitive', false);
|
||||
$type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed');
|
||||
$displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null;
|
||||
$diffMode = data_get($item, 'diff_mode', 'default');
|
||||
|
||||
$oldFull = null;
|
||||
$newFull = null;
|
||||
|
||||
if ($sensitive) {
|
||||
$oldDisplay = $previous === null ? '-' : '••••••••';
|
||||
$newDisplay = $current === null ? '-' : '••••••••';
|
||||
} elseif ($diffMode === 'lines' && $type === 'changed') {
|
||||
[$oldDisplay, $newDisplay] = $this->changedLines(
|
||||
data_get($previous, 'display_full'),
|
||||
data_get($current, 'display_full'),
|
||||
);
|
||||
|
||||
// No line-level difference (e.g. only reordering) — fall back to the summary.
|
||||
if ($oldDisplay === '-' && $newDisplay === '-') {
|
||||
$oldDisplay = data_get($previous, 'display_value', '-');
|
||||
$newDisplay = data_get($current, 'display_value', '-');
|
||||
}
|
||||
|
||||
// Expansion reveals the full changed lines, not the entire value.
|
||||
$oldFull = $this->expandableText($oldDisplay);
|
||||
$newFull = $this->expandableText($newDisplay);
|
||||
} else {
|
||||
$oldDisplay = data_get($previous, 'display_value', '-');
|
||||
$newDisplay = data_get($current, 'display_value', '-');
|
||||
$oldFull = data_get($previous, 'display_full');
|
||||
$newFull = data_get($current, 'display_full');
|
||||
}
|
||||
|
||||
$expandable = ! $sensitive && (filled($oldFull) || filled($newFull));
|
||||
|
||||
$changes[] = [
|
||||
'key' => $key,
|
||||
@@ -37,14 +85,54 @@ class ConfigurationDiffer
|
||||
'impact' => data_get($item, 'impact', 'redeploy'),
|
||||
'sensitive' => $sensitive,
|
||||
'display_summary' => $displaySummary,
|
||||
'old_display_value' => $sensitive ? ($previous === null ? '-' : '••••••••') : data_get($previous, 'display_value', '-'),
|
||||
'new_display_value' => $sensitive ? ($current === null ? '-' : '••••••••') : data_get($current, 'display_value', '-'),
|
||||
'old_display_value' => $oldDisplay,
|
||||
'new_display_value' => $newDisplay,
|
||||
'old_full_value' => $oldFull,
|
||||
'new_full_value' => $newFull,
|
||||
'expandable' => $expandable,
|
||||
];
|
||||
}
|
||||
|
||||
return ConfigurationDiff::fromChanges($changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce two multi-line values to only the lines that differ, so the modal
|
||||
* shows just the changed container labels instead of the whole block.
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function changedLines(?string $old, ?string $new): array
|
||||
{
|
||||
$oldLines = $this->textLines($old);
|
||||
$newLines = $this->textLines($new);
|
||||
|
||||
$removed = array_values(array_diff($oldLines, $newLines));
|
||||
$added = array_values(array_diff($newLines, $oldLines));
|
||||
|
||||
return [
|
||||
$removed === [] ? '-' : implode("\n", $removed),
|
||||
$added === [] ? '-' : implode("\n", $added),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function textLines(?string $value): array
|
||||
{
|
||||
if (blank($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Keep leading indentation (meaningful for YAML/compose), drop trailing whitespace.
|
||||
return collect(preg_split('/\r\n|\r|\n/', (string) $value))
|
||||
->map(fn (string $line): string => rtrim($line))
|
||||
->filter(fn (string $line): bool => trim($line) !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, array<string, mixed>>
|
||||
|
||||
Reference in new issue
Block a user