mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-13 19:09:50 +00:00
feat(ui): improve configuration changes modal
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/**
|
||||
* Stores an array as an encrypted JSON string at rest. Tolerates legacy
|
||||
* plaintext JSON rows written before the column was encrypted, so existing
|
||||
* snapshots keep decoding instead of throwing.
|
||||
*
|
||||
* @implements CastsAttributes<array<mixed>|null, array<mixed>|null>
|
||||
*/
|
||||
class EncryptedArrayCast implements CastsAttributes
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = Crypt::decryptString($value);
|
||||
} catch (DecryptException) {
|
||||
// Legacy plaintext JSON written before this column was encrypted.
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $value, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,6 @@ class ConfigurationChecker extends Component
|
||||
|
||||
public array $configurationDiff = [];
|
||||
|
||||
public array $groupedConfigurationChanges = [];
|
||||
|
||||
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
|
||||
|
||||
public function getListeners(): array
|
||||
@@ -50,21 +48,56 @@ class ConfigurationChecker extends Component
|
||||
$this->configurationChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Members must never see environment variable values, so redact every
|
||||
* environment-section change before it is serialized to the browser.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $changes
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function redactEnvironmentChanges(array $changes, bool $redact): array
|
||||
{
|
||||
if (! $redact) {
|
||||
return $changes;
|
||||
}
|
||||
|
||||
return collect($changes)
|
||||
->map(function (array $change): array {
|
||||
if (data_get($change, 'section') !== 'environment') {
|
||||
return $change;
|
||||
}
|
||||
|
||||
$change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••';
|
||||
$change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••';
|
||||
$change['old_full_value'] = null;
|
||||
$change['new_full_value'] = null;
|
||||
$change['expandable'] = false;
|
||||
$change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null;
|
||||
|
||||
return $change;
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function configurationChanged(): void
|
||||
{
|
||||
$this->resource->refresh();
|
||||
|
||||
if ($this->resource instanceof Application) {
|
||||
$diff = $this->resource->pendingDeploymentConfigurationDiff();
|
||||
// Fail closed: only owners/admins may see unlocked env values.
|
||||
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
|
||||
|
||||
$array = $diff->toArray();
|
||||
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
|
||||
|
||||
$this->isConfigurationChanged = $diff->isChanged();
|
||||
$this->configurationDiff = $diff->toArray();
|
||||
$this->groupedConfigurationChanges = $diff->groupedChanges();
|
||||
$this->configurationDiff = $array;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isConfigurationChanged = $this->resource->isConfigurationChanged();
|
||||
$this->configurationDiff = [];
|
||||
$this->groupedConfigurationChanges = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\EncryptedArrayCast;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -74,11 +75,24 @@ class ApplicationDeploymentQueue extends Model
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The configuration snapshot/diff hold full (decrypted on read) configuration,
|
||||
* including unlocked environment variable values. They are only meant for the
|
||||
* in-app diff modal (which redacts per role) and must never be serialized by the
|
||||
* API, so hide them globally as defense in depth.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'configuration_snapshot',
|
||||
'configuration_diff',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pull_request_id' => 'integer',
|
||||
'finished_at' => 'datetime',
|
||||
'configuration_snapshot' => 'array',
|
||||
'configuration_diff' => 'array',
|
||||
'configuration_snapshot' => EncryptedArrayCast::class,
|
||||
'configuration_diff' => EncryptedArrayCast::class,
|
||||
];
|
||||
|
||||
public function application()
|
||||
|
||||
@@ -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>>
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* The configuration snapshot/diff now store an encrypted blob (not valid
|
||||
* JSON), so the columns must hold arbitrary text instead of json.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
])
|
||||
|
||||
@php
|
||||
$changes = collect(data_get($diff, 'changes', []))->filter(fn ($change) => data_get($change, 'key') !== 'domains.custom_labels')->values()->all();
|
||||
$changes = collect(data_get($diff, 'changes', []))->values()->all();
|
||||
$count = count($changes);
|
||||
$requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build');
|
||||
@endphp
|
||||
@@ -41,16 +41,63 @@
|
||||
</div>
|
||||
<div class="divide-y divide-neutral-300 dark:divide-coolgray-200">
|
||||
@foreach ($sectionChanges as $change)
|
||||
@php
|
||||
$changeKey = (string) data_get($change, 'key');
|
||||
$expandable = data_get($change, 'expandable', false);
|
||||
$oldDisplay = (string) data_get($change, 'old_display_value');
|
||||
$newDisplay = (string) data_get($change, 'new_display_value');
|
||||
$oldFull = data_get($change, 'old_full_value') ?? $oldDisplay;
|
||||
$newFull = data_get($change, 'new_full_value') ?? $newDisplay;
|
||||
$label = (string) data_get($change, 'label');
|
||||
$labelTruncated = mb_strlen($label) > 20;
|
||||
$rowExpandable = $expandable || $labelTruncated;
|
||||
@endphp
|
||||
<div class="grid grid-cols-[12rem_1fr_1.5rem_1fr] items-start gap-2 px-3 py-1.5 text-neutral-700 dark:text-neutral-300">
|
||||
<div class="shrink-0 font-medium text-black dark:text-white">
|
||||
{{ data_get($change, 'label') }}
|
||||
<div class="min-w-0 shrink-0 font-medium text-black dark:text-white">
|
||||
@if ($rowExpandable)
|
||||
<div class="break-words"
|
||||
:class="expandedRows['{{ $changeKey }}'] ? '' : 'truncate'"
|
||||
x-text="expandedRows['{{ $changeKey }}'] ? @js($label) : @js((string) str($label)->limit(20))"></div>
|
||||
@else
|
||||
{{ $label }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="truncate text-red-700 dark:text-red-400/80" title="{{ data_get($change, 'old_display_value') }}">
|
||||
{{ data_get($change, 'old_display_value') }}
|
||||
<div class="min-w-0 text-red-700 dark:text-red-400/80">
|
||||
@if ($expandable)
|
||||
<div class="break-words"
|
||||
:class="expandedRows['{{ $changeKey }}'] ? 'whitespace-pre-wrap' : 'truncate'"
|
||||
x-text="expandedRows['{{ $changeKey }}'] ? @js($oldFull) : @js($oldDisplay)"></div>
|
||||
@else
|
||||
<div class="truncate">{{ $oldDisplay }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-center text-neutral-500 dark:text-neutral-400">→</div>
|
||||
<div class="truncate text-green-700 dark:text-green-500" title="{{ data_get($change, 'new_display_value') }}">
|
||||
{{ data_get($change, 'new_display_value') }}
|
||||
<div class="flex min-w-0 items-start gap-1 text-green-700 dark:text-green-500">
|
||||
<div class="min-w-0 flex-1">
|
||||
@if ($expandable)
|
||||
<div class="break-words"
|
||||
:class="expandedRows['{{ $changeKey }}'] ? 'whitespace-pre-wrap' : 'truncate'"
|
||||
x-text="expandedRows['{{ $changeKey }}'] ? @js($newFull) : @js($newDisplay)"></div>
|
||||
@else
|
||||
<div class="truncate">{{ $newDisplay }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@if ($rowExpandable)
|
||||
<button type="button"
|
||||
x-on:click="expandedRows['{{ $changeKey }}'] = ! expandedRows['{{ $changeKey }}']"
|
||||
:aria-expanded="!! expandedRows['{{ $changeKey }}']"
|
||||
title="Toggle full value"
|
||||
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center text-neutral-400 transition hover:text-black dark:hover:text-white">
|
||||
<svg x-show="! expandedRows['{{ $changeKey }}']" class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M13.28 7.78l3.22-3.22v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-3.22 3.22a.75.75 0 0 0 1.06 1.06ZM2 17.25v-4.5a.75.75 0 0 1 1.5 0v2.69l3.22-3.22a.75.75 0 0 1 1.06 1.06L4.56 16.5h2.69a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg x-show="expandedRows['{{ $changeKey }}']" x-cloak class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3.28 2.22a.75.75 0 0 0-1.06 1.06L5.44 6.5H2.75a.75.75 0 0 0 0 1.5h4.5A.75.75 0 0 0 8 7.25v-4.5a.75.75 0 0 0-1.5 0v2.69L3.28 2.22ZM13.5 2.75a.75.75 0 0 0-1.5 0v4.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 0-1.5h-2.69l3.22-3.22a.75.75 0 0 0-1.06-1.06L13.5 5.44V2.75ZM3.28 17.78l3.22-3.22v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-3.22 3.22a.75.75 0 1 0 1.06 1.06ZM12 12.75c0-.414.336-.75.75-.75h4.5a.75.75 0 0 1 0 1.5h-2.69l3.22 3.22a.75.75 0 1 1-1.06 1.06l-3.22-3.22v2.69a.75.75 0 0 1-1.5 0v-4.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div>
|
||||
@if ($isConfigurationChanged && !is_null($resource->config_hash) && !$resource->isExited())
|
||||
<div x-data="{ configurationDiffModalOpen: false }">
|
||||
<div x-data="{ configurationDiffModalOpen: false, expandedRows: {} }">
|
||||
<x-popup-small>
|
||||
<x-slot:title>
|
||||
The latest configuration has not been applied
|
||||
|
||||
Reference in New Issue
Block a user