feat(ui): unify resource nav sidebars and polish project layouts
Extract application/database/service configuration sidebars and unified headings; improve clone-to-environment, embedded deployments, storage actions, OAuth icons, terminal, and shared form components.
@@ -42,6 +42,10 @@ class Index extends Component
|
||||
|
||||
public array $sourceFilterOptions = [];
|
||||
|
||||
public bool $embedded = false;
|
||||
|
||||
public ?string $selectedDeploymentUuid = null;
|
||||
|
||||
protected $queryString = ['pull_request_id'];
|
||||
|
||||
public function getListeners()
|
||||
@@ -79,6 +83,9 @@ class Index extends Component
|
||||
}
|
||||
|
||||
$this->application = $application;
|
||||
if ($this->embedded) {
|
||||
$this->defaultTake = 3;
|
||||
}
|
||||
$this->loadPullRequestOptions();
|
||||
$this->loadDeploymentFilterOptions();
|
||||
['deployments' => $deployments, 'count' => $count] = $application->deployments(
|
||||
@@ -90,7 +97,11 @@ class Index extends Component
|
||||
);
|
||||
$this->deployments = $deployments;
|
||||
$this->deployments_count = $count;
|
||||
$this->current_url = url()->current();
|
||||
$this->current_url = route('project.application.deployment.index', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
]);
|
||||
$this->updateCurrentPage();
|
||||
$this->showMore();
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ class Index extends Component
|
||||
'database_uuid' => $database->uuid,
|
||||
]);
|
||||
}
|
||||
$this->database = $database;
|
||||
$this->database = $database->load([
|
||||
'scheduledBackups' => fn ($query) => $query->withCount('executions'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -76,20 +76,10 @@ class ConfigurationChecker extends Component
|
||||
|
||||
public function configurationChanged(): void
|
||||
{
|
||||
// Banner only needs a lightweight summary in the Livewire snapshot.
|
||||
$this->loadConfigurationState(includeChanges: false);
|
||||
$this->loadConfigurationState();
|
||||
}
|
||||
|
||||
public function refreshConfigurationChanges(): void
|
||||
{
|
||||
// Full change list is only needed when the user opens "View changes".
|
||||
$this->loadConfigurationState(includeChanges: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $includeChanges When false, only summary keys are stored (smaller HTML/snapshots).
|
||||
*/
|
||||
private function loadConfigurationState(bool $includeChanges = false): void
|
||||
private function loadConfigurationState(): void
|
||||
{
|
||||
$this->resource->refresh();
|
||||
|
||||
@@ -99,15 +89,6 @@ class ConfigurationChecker extends Component
|
||||
|
||||
$array = $diff->toArray();
|
||||
|
||||
if (! $includeChanges) {
|
||||
$this->configurationDiff = [
|
||||
'count' => data_get($array, 'count', 0),
|
||||
'requires_build' => (bool) data_get($array, 'requires_build', false),
|
||||
];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail closed: only owners/admins may see unlocked env values.
|
||||
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
|
||||
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
|
||||
|
||||
@@ -55,11 +55,19 @@ class ResourceOperations extends Component
|
||||
$this->cloneVolumeData = $value;
|
||||
}
|
||||
|
||||
public function cloneTo($destination_uuid)
|
||||
public function cloneTo($destination_uuid, $environment_id = null)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$new_environment = $this->resource->environment;
|
||||
if ($environment_id !== null) {
|
||||
$new_environment = Environment::ownedByCurrentTeam()->find($environment_id);
|
||||
if (! $new_environment) {
|
||||
return $this->addError('environment_id', 'Environment not found.');
|
||||
}
|
||||
}
|
||||
|
||||
$new_destination = find_resource_destination_for_current_team($destination_uuid);
|
||||
if (! $new_destination) {
|
||||
return $this->addError('destination_id', 'Destination not found.');
|
||||
@@ -71,11 +79,14 @@ class ResourceOperations extends Component
|
||||
}
|
||||
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
$new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData);
|
||||
$new_resource = clone_application($this->resource, $new_destination, [
|
||||
'uuid' => $uuid,
|
||||
'environment_id' => $new_environment->id,
|
||||
], $this->cloneVolumeData);
|
||||
|
||||
$route = route('project.application.configuration', [
|
||||
'project_uuid' => $this->projectUuid,
|
||||
'environment_uuid' => $this->environmentUuid,
|
||||
'project_uuid' => $new_environment->project->uuid,
|
||||
'environment_uuid' => $new_environment->uuid,
|
||||
'application_uuid' => $new_resource->uuid,
|
||||
]).'#resource-operations';
|
||||
|
||||
@@ -100,6 +111,7 @@ class ResourceOperations extends Component
|
||||
'name' => $this->resource->name.'-clone-'.$uuid,
|
||||
'status' => 'exited',
|
||||
'started_at' => null,
|
||||
'environment_id' => $new_environment->id,
|
||||
'destination_id' => $new_destination->id,
|
||||
'destination_type' => $new_destination->getMorphClass(),
|
||||
]);
|
||||
@@ -211,8 +223,8 @@ class ResourceOperations extends Component
|
||||
}
|
||||
|
||||
$route = route('project.database.configuration', [
|
||||
'project_uuid' => $this->projectUuid,
|
||||
'environment_uuid' => $this->environmentUuid,
|
||||
'project_uuid' => $new_environment->project->uuid,
|
||||
'environment_uuid' => $new_environment->uuid,
|
||||
'database_uuid' => $new_resource->uuid,
|
||||
]).'#resource-operations';
|
||||
|
||||
@@ -226,6 +238,7 @@ class ResourceOperations extends Component
|
||||
])->fill([
|
||||
'uuid' => $uuid,
|
||||
'name' => $this->resource->name.'-clone-'.$uuid,
|
||||
'environment_id' => $new_environment->id,
|
||||
'destination_id' => $new_destination->id,
|
||||
'destination_type' => $new_destination->getMorphClass(),
|
||||
'server_id' => $new_destination->server_id,
|
||||
@@ -354,8 +367,8 @@ class ResourceOperations extends Component
|
||||
$new_resource->parse();
|
||||
|
||||
$route = route('project.service.configuration', [
|
||||
'project_uuid' => $this->projectUuid,
|
||||
'environment_uuid' => $this->environmentUuid,
|
||||
'project_uuid' => $new_environment->project->uuid,
|
||||
'environment_uuid' => $new_environment->uuid,
|
||||
'service_uuid' => $new_resource->uuid,
|
||||
]).'#resource-operations';
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ class All extends Component
|
||||
|
||||
public bool $showActionsColumn = false;
|
||||
|
||||
public bool $showBackupAction = false;
|
||||
|
||||
public bool $isComposeOrService = false;
|
||||
|
||||
public bool $canUpdate = false;
|
||||
@@ -47,7 +49,8 @@ class All extends Component
|
||||
$this->canUpdate = (bool) auth()->user()?->can('update', $this->resource);
|
||||
$this->supportsPreviewSuffix = $this->resource instanceof Application
|
||||
&& $this->resource->git_based();
|
||||
$this->showActionsColumn = $this->resource instanceof Application
|
||||
$this->showActionsColumn = $this->canUpdate;
|
||||
$this->showBackupAction = $this->resource instanceof Application
|
||||
|| $this->resource instanceof ServiceApplication
|
||||
|| $this->resource instanceof ServiceDatabase;
|
||||
$this->isComposeOrService = $this->resource->type() === 'service'
|
||||
@@ -175,7 +178,7 @@ class All extends Component
|
||||
{
|
||||
$this->volumeBackupMeta = [];
|
||||
|
||||
if (! $this->showActionsColumn) {
|
||||
if (! $this->showBackupAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class Button extends Component
|
||||
public ?string $canGate = null,
|
||||
public mixed $canResource = null,
|
||||
public bool $autoDisable = true,
|
||||
public bool $isHighlighted = false,
|
||||
public ?string $tooltip = null,
|
||||
) {
|
||||
// Handle authorization-based disabling
|
||||
|
||||
@@ -1340,7 +1340,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||
if ($isPullRequest) {
|
||||
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
|
||||
}
|
||||
$composeRedirect = data_get($domains, "$changedServiceName.redirect");
|
||||
$domainServiceName = findComposeServiceName((string) $serviceName, $domains->keys());
|
||||
$composeRedirect = data_get($domains->get($domainServiceName), 'redirect');
|
||||
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
|
||||
? $composeRedirect
|
||||
: 'both';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Authentik</title><path d="M13.96 9.01h-.84V7.492h-1.234v3.663H5.722c.34.517.538.982.538 1.152 0 .46-1.445 3.059-3.197 3.059C.8 15.427-.745 12.8.372 10.855a3.062 3.062 0 0 1 2.691-1.606c1.04 0 1.971.915 2.557 1.755V6.577a3.773 3.773 0 0 1 3.77-3.769h10.84C22.31 2.808 24 4.5 24 6.577v10.845a3.773 3.773 0 0 1-3.77 3.769h-1.6V17.5h-7.64v3.692h-1.6a3.773 3.773 0 0 1-3.77-3.769v-3.41h12.114v-6.52h-1.59v.893h-.84v-.893H13.96v1.516Zm-9.956 1.845c-.662-.703-1.578-.544-2.209 0-2.105 2.054 1.338 5.553 3.302 1.447a5.395 5.395 0 0 0-1.093-1.447Z"/></svg>
|
||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Microsoft Azure</title><path d="M22.379 23.343a1.62 1.62 0 0 0 1.536-2.14v.002L17.35 1.76A1.62 1.62 0 0 0 15.816.657H8.184A1.62 1.62 0 0 0 6.65 1.76L.086 21.204a1.62 1.62 0 0 0 1.536 2.139h4.741a1.62 1.62 0 0 0 1.535-1.103l.977-2.892 4.947 3.675c.28.208.618.32.966.32m-3.084-12.531 3.624 10.739a.54.54 0 0 1-.51.713v-.001h-.03a.54.54 0 0 1-.322-.106l-9.287-6.9h4.853m6.313 7.006c.116-.326.13-.694.007-1.058L9.79 1.76a1.722 1.722 0 0 0-.007-.02h6.034a.54.54 0 0 1 .512.366l6.562 19.445a.54.54 0 0 1-.338.684"/></svg>
|
||||
|
After Width: | Height: | Size: 593 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Clerk</title><path d="m21.47 20.829-2.881-2.881a.572.572 0 0 0-.7-.084 6.854 6.854 0 0 1-7.081 0 .576.576 0 0 0-.7.084l-2.881 2.881a.576.576 0 0 0-.103.69.57.57 0 0 0 .166.186 12 12 0 0 0 14.113 0 .58.58 0 0 0 .239-.423.576.576 0 0 0-.172-.453Zm.002-17.668-2.88 2.88a.569.569 0 0 1-.701.084A6.857 6.857 0 0 0 8.724 8.08a6.862 6.862 0 0 0-1.222 3.692 6.86 6.86 0 0 0 .978 3.764.573.573 0 0 1-.083.699l-2.881 2.88a.567.567 0 0 1-.864-.063A11.993 11.993 0 0 1 6.771 2.7a11.99 11.99 0 0 1 14.637-.405.566.566 0 0 1 .232.418.57.57 0 0 1-.168.448Zm-7.118 12.261a3.427 3.427 0 1 0 0-6.854 3.427 3.427 0 0 0 0 6.854Z"/></svg>
|
||||
|
After Width: | Height: | Size: 695 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill="#fff"><path fill-rule="evenodd" clip-rule="evenodd" d="M64 1.512c-23.493 0-42.545 19.047-42.545 42.545 0 18.797 12.19 34.745 29.095 40.37 2.126.394 2.907-.923 2.907-2.047 0-1.014-.04-4.366-.058-7.92-11.837 2.573-14.334-5.02-14.334-5.02-1.935-4.918-4.724-6.226-4.724-6.226-3.86-2.64.29-2.586.29-2.586 4.273.3 6.523 4.385 6.523 4.385 3.794 6.504 9.953 4.623 12.38 3.536.383-2.75 1.485-4.628 2.702-5.69-9.45-1.075-19.384-4.724-19.384-21.026 0-4.645 1.662-8.44 4.384-11.42-.442-1.072-1.898-5.4.412-11.26 0 0 3.572-1.142 11.7 4.363 3.395-.943 7.035-1.416 10.65-1.432 3.616.017 7.258.49 10.658 1.432 8.12-5.504 11.688-4.362 11.688-4.362 2.316 5.86.86 10.187.418 11.26 2.728 2.978 4.378 6.774 4.378 11.42 0 16.34-9.953 19.938-19.427 20.99 1.526 1.32 2.886 3.91 2.886 7.88 0 5.692-.048 10.273-.048 11.674 0 1.13.766 2.458 2.922 2.04 16.896-5.632 29.07-21.574 29.07-40.365C106.545 20.56 87.497 1.512 64 1.512z"/><path d="M37.57 62.596c-.095.212-.428.275-.73.13-.31-.14-.482-.427-.382-.64.09-.216.424-.277.733-.132.31.14.486.43.38.642zM39.293 64.52c-.203.187-.6.1-.87-.198-.278-.297-.33-.694-.124-.884.208-.188.593-.1.87.197.28.3.335.693.123.884zm1.677 2.448c-.26.182-.687.012-.95-.367-.262-.377-.262-.83.005-1.013.264-.182.684-.018.95.357.262.385.262.84-.005 1.024zm2.298 2.368c-.233.257-.73.188-1.093-.163-.372-.343-.475-.83-.242-1.087.237-.257.736-.185 1.102.163.37.342.482.83.233 1.086zm3.172 1.374c-.104.334-.582.485-1.064.344-.482-.146-.796-.536-.7-.872.1-.336.582-.493 1.067-.342.48.144.795.53.696.87zm3.48.255c.013.35-.396.642-.902.648-.508.012-.92-.272-.926-.618 0-.354.4-.642.908-.65.506-.01.92.272.92.62zm3.24-.551c.06.342-.29.694-.793.787-.494.092-.95-.12-1.014-.46-.06-.35.297-.7.79-.792.503-.088.953.118 1.017.466zm0 0"/></g><path d="M24.855 108.302h-10.7a.5.5 0 00-.5.5v5.232a.5.5 0 00.5.5h4.173v6.5s-.937.32-3.53.32c-3.056 0-7.327-1.116-7.327-10.508 0-9.393 4.448-10.63 8.624-10.63 3.614 0 5.17.636 6.162.943.31.094.6-.216.6-.492l1.193-5.055a.468.468 0 00-.192-.39c-.403-.288-2.857-1.66-9.058-1.66-7.144 0-14.472 3.038-14.472 17.65 0 14.61 8.39 16.787 15.46 16.787 5.854 0 9.405-2.502 9.405-2.502.146-.08.162-.285.162-.38v-16.316a.5.5 0 00-.5-.5zM79.506 94.81H73.48a.5.5 0 00-.498.503l.002 11.644h-9.392V95.313a.5.5 0 00-.497-.503H57.07a.5.5 0 00-.498.503v31.53c0 .277.224.503.498.503h6.025a.5.5 0 00.497-.504v-13.486h9.392l-.016 13.486c0 .278.224.504.5.504h6.038a.5.5 0 00.497-.504v-31.53a.497.497 0 00-.497-.502zm-47.166.717c-2.144 0-3.884 1.753-3.884 3.923 0 2.167 1.74 3.925 3.884 3.925 2.146 0 3.885-1.758 3.885-3.925 0-2.17-1.74-3.923-3.885-3.923zm2.956 9.608H29.29c-.276 0-.522.284-.522.56v20.852c0 .613.382.795.876.795h5.41c.595 0 .74-.292.74-.805v-20.899a.5.5 0 00-.498-.502zm67.606.047h-5.98a.5.5 0 00-.496.504v15.46s-1.52 1.11-3.675 1.11-2.727-.977-2.727-3.088v-13.482a.5.5 0 00-.497-.504h-6.068a.502.502 0 00-.498.504v14.502c0 6.27 3.495 7.804 8.302 7.804 3.944 0 7.124-2.18 7.124-2.18s.15 1.15.22 1.285c.07.136.247.273.44.273l3.86-.017a.502.502 0 00.5-.504l-.003-21.166a.504.504 0 00-.5-.502zm16.342-.708c-3.396 0-5.706 1.515-5.706 1.515V95.312a.5.5 0 00-.497-.503H107a.5.5 0 00-.5.503v31.53a.5.5 0 00.5.503h4.192c.19 0 .332-.097.437-.268.103-.17.254-1.454.254-1.454s2.47 2.34 7.148 2.34c5.49 0 8.64-2.784 8.64-12.502s-5.03-10.988-8.428-10.988zm-2.36 17.764c-2.073-.063-3.48-1.004-3.48-1.004v-9.985s1.388-.85 3.09-1.004c2.153-.193 4.228.458 4.228 5.594 0 5.417-.935 6.486-3.837 6.398zm-63.689-.118c-.263 0-.937.107-1.63.107-2.22 0-2.973-1.032-2.973-2.368v-8.866h4.52a.5.5 0 00.5-.504v-4.856a.5.5 0 00-.5-.502h-4.52l-.007-5.97c0-.227-.116-.34-.378-.34h-6.16c-.238 0-.367.106-.367.335v6.17s-3.087.745-3.295.805a.5.5 0 00-.36.48v3.877a.5.5 0 00.497.503h3.158v9.328c0 6.93 4.86 7.61 8.14 7.61 1.497 0 3.29-.48 3.586-.59.18-.067.283-.252.283-.453l.004-4.265a.51.51 0 00-.5-.502z" fill="#fff"/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 822 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 380 380"><defs><style>.cls-1{fill:#e24329;}.cls-2{fill:#fc6d26;}.cls-3{fill:#fca326;}</style></defs><g id="LOGO"><path class="cls-1" d="M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-2" d="M282.83,170.73l-.27-.69a88.3,88.3,0,0,0-35.15,15.8L190,229.25c19.55,14.79,36.57,27.64,36.57,27.64l40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/><path class="cls-3" d="M153.43,256.89l19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91S209.55,244,190,229.25C170.45,244,153.43,256.89,153.43,256.89Z"/><path class="cls-2" d="M132.58,185.84A88.19,88.19,0,0,0,97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82s17-12.85,36.57-27.64Z"/></g></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitLab</title><path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 574 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>
|
||||
|
After Width: | Height: | Size: 457 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Infomaniak</title><path d="M2.4 0A2.395 2.395 0 0 0 0 2.4v19.2C0 22.9296 1.0704 24 2.4 24h19.2c1.3296 0 2.4-1.0704 2.4-2.4V2.4C24 1.0704 22.9296 0 21.6 0H10.112v11.7119l3.648-4.128h6l-4.58 4.3506 4.868 8.1296h-5.52l-2.5938-5.0211L10.112 16.8v3.264H5.12V0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 342 B |
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="24" height="24" viewBox="0 0 467 468" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,0,-492)">
|
||||
<g id="zitadel-logo-solo-lightdesign" transform="matrix(0.564847,0,0,0.659318,-1282.85,492.925)">
|
||||
<rect x="2271.15" y="0" width="826.773" height="708.241" style="fill:none;"/>
|
||||
<g transform="matrix(4.96737,-1.14029,1.331,4.25561,-5923.46,-2258.26)">
|
||||
<path d="M1493.5,1056.38L1493.5,1037L1496.5,1037L1496.5,1061.62L1426.02,1020.38L1496.5,979.392L1496.5,1004L1493.5,1004L1493.5,984.608L1431.98,1020.39L1493.5,1056.38Z" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(31.0036,0,0,15.0393,-397275,-666.457)">
|
||||
<g transform="matrix(-0.0429306,-0.282967,0.160219,-0.0758207,12884.5,137.392)">
|
||||
<path d="M212.517,110L200.392,110L190,92L179.608,110L167.483,110L190,71L212.517,110Z" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.160219,0.0758207,-0.0429306,0.282967,12878.9,10.8747)">
|
||||
<path d="M212.517,110L200.392,110L190,92L179.608,110L167.483,110L190,71L212.517,110Z" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.117289,0.207146,-0.117289,-0.207146,12943.8,65.7)">
|
||||
<path d="M212.517,110L200.392,110L190,92L179.608,110L167.483,110L190,71L212.517,110Z" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.160219,-0.0758207,0.0429306,-0.282967,12917.4,132.195)">
|
||||
<path d="M139.622,117L149,142L130.244,142L139.622,117Z" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.117289,0.207146,0.117289,0.207146,12897.8,5.87512)">
|
||||
<path d="M139.622,117L149,142L130.244,142L139.622,117Z" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(-0.0429306,-0.282967,-0.160219,0.0758207,12936.8,97.6441)">
|
||||
<path d="M139.622,117L149,142L130.244,142L139.622,117Z" style="fill:#000;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.331,4.25561,-5928.43,-2257.12)">
|
||||
<circle cx="1496" cy="1004" r="7" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.331,4.25561,-5884.5,-2116.69)">
|
||||
<circle cx="1496" cy="1004" r="7" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.331,4.25561,-5855.22,-2023.06)">
|
||||
<circle cx="1496" cy="1004" r="7" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.331,4.25561,-6234.47,-2112.14)">
|
||||
<circle cx="1496" cy="1004" r="7" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.331,4.25561,-5957.71,-2350.75)">
|
||||
<circle cx="1496" cy="1004" r="7" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.16463,3.72366,-5477.99,-831.33)">
|
||||
<path d="M1499.26,757.787C1499.26,757.787 1497.37,756.489 1497,755.2C1496.71,754.182 1496.57,750.662 1496.54,750C1496.41,747.303 1499.21,745.644 1499.21,745.644L1490.01,745.835C1490.01,745.835 1493.15,745.713 1493.46,750C1493.51,750.661 1493.23,753.476 1493,755.2C1492.91,756.447 1491.2,757.668 1491.2,757.668L1499.26,757.787Z" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.16463,3.72366,-5404.79,-597.271)">
|
||||
<path d="M1495,760L1495,744" style="fill:none;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.16463,3.72366,-5404.79,-597.271)">
|
||||
<path d="M1498.27,757.077C1498.27,757.077 1496.71,756.46 1496.65,754.8C1496.65,753.658 1496.64,753.281 1496.65,752.016C1496.62,751.334 1496.59,750.608 1496.65,749.949C1496.78,746.836 1498.5,746.156 1498.5,746.156L1491.46,745.931C1491.46,745.931 1493.37,746.719 1493.65,749.83C1493.71,750.489 1493.69,751.528 1493.65,752.209C1493.64,753.331 1493.64,753.413 1493.65,754.518C1493.68,756.334 1492.58,756.827 1492.58,756.827L1498.27,757.077Z" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.16463,3.72366,-5770.62,-677.495)">
|
||||
<path d="M1496.17,759.473L1555.54,720.014" style="fill:none;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,1.16463,3.72366,-5770.62,-677.495)">
|
||||
<path d="M1500.86,762.056C1500.86,762.056 1499.86,760.4 1503.09,757.456C1504.91,755.797 1507.33,754.151 1509.98,752.255C1514.82,748.79 1520.68,744.94 1526.52,741.049C1531.45,737.766 1536.38,734.479 1540.82,731.68C1544.52,729.349 1547.85,727.296 1550.54,725.8C1551.07,725.506 1551.6,725.329 1552.05,725.029C1554.73,723.257 1556.85,724.968 1556.85,724.968L1552.23,716.282C1552.23,716.282 1551.99,719.454 1550,720.997C1549.57,721.333 1549.15,721.741 1548.67,722.12C1546.2,724.053 1542.99,726.344 1539.39,728.867C1535.06,731.898 1530.13,735.166 1525.19,738.438C1519.35,742.314 1513.52,746.234 1508.49,749.329C1505.74,751.023 1503.28,752.577 1501.13,753.598C1497.99,755.086 1495.28,753.617 1495.28,753.617L1500.86,762.056Z" style="fill:#000;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,-1.16463,-3.72366,-3997,4993.28)">
|
||||
<path d="M1496.17,759.473L1555.54,720.014" style="fill:none;"/>
|
||||
</g>
|
||||
<g transform="matrix(4.96737,-1.14029,-1.16463,-3.72366,-3997,4993.28)">
|
||||
<path d="M1496.1,754.362C1496.1,754.362 1497.2,755.607 1501.13,753.598C1503.25,752.509 1505.74,751.023 1508.49,749.329C1513.52,746.234 1519.35,742.314 1525.19,738.438C1530.13,735.166 1534.94,731.832 1539.27,728.802C1542.87,726.279 1549.36,722.059 1549.81,721.75C1552.75,719.73 1552.18,718.196 1552.18,718.196L1555.28,724.152C1555.28,724.152 1553.77,722.905 1551.37,724.681C1550.93,725.006 1544.52,729.349 1540.82,731.68C1536.38,734.479 1531.45,737.766 1526.52,741.049C1520.68,744.94 1514.82,748.79 1509.98,752.255C1507.33,754.151 1504.89,755.771 1503.09,757.456C1499.47,760.841 1501.26,763.283 1501.26,763.283L1496.1,754.362Z" style="fill:#000;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-41.5984,155.247,-155.247,-41.5984,201.516,76.8392)"><stop offset="0" style="stop-color:rgb(255,143,0);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(254,0,255);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(155.247,-41.5984,41.5984,155.247,110.08,195.509)"><stop offset="0" style="stop-color:rgb(255,143,0);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(254,0,255);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-113.649,-113.649,113.649,-113.649,258.31,215.618)"><stop offset="0" style="stop-color:rgb(255,143,0);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(254,0,255);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-155.247,41.5984,-41.5984,-155.247,220.914,144.546)"><stop offset="0" style="stop-color:rgb(255,143,0);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(254,0,255);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear5" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-113.649,113.649,113.649,113.649,206.837,124.661)"><stop offset="0" style="stop-color:rgb(255,143,0);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(254,0,255);stop-opacity:1"/></linearGradient>
|
||||
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-41.5984,-155.247,-155.247,41.5984,152.054,262.8)"><stop offset="0" style="stop-color:rgb(255,143,0);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(254,0,255);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -433,6 +433,55 @@ tr td:first-child {
|
||||
opacity 220ms ease;
|
||||
}
|
||||
|
||||
.application-console-shell[data-console-theme="system"],
|
||||
.terminal-fullscreen-shell[data-console-theme="system"] {
|
||||
--console-theme-background: #fff;
|
||||
--console-theme-opacity: 1;
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header {
|
||||
color: #52525b;
|
||||
border-color: rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header [class*="text-white"] {
|
||||
color: #52525b !important;
|
||||
}
|
||||
|
||||
html.dark .application-console-shell[data-console-theme="system"],
|
||||
html.dark .terminal-fullscreen-shell[data-console-theme="system"] {
|
||||
--console-theme-background: #121214;
|
||||
}
|
||||
|
||||
.console-theme-selector {
|
||||
scrollbar-color: rgb(161 161 170) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.console-theme-selector::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.console-theme-selector::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 9999px;
|
||||
background: rgb(161 161 170);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.console-theme-selector::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.dark .console-theme-selector {
|
||||
scrollbar-color: rgb(255 255 255 / 0.28) transparent;
|
||||
}
|
||||
|
||||
html.dark .console-theme-selector::-webkit-scrollbar-thumb {
|
||||
background: rgb(255 255 255 / 0.28);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.application-console-shell[data-console-theme="shadows-midnight"],
|
||||
.terminal-fullscreen-shell[data-console-theme="shadows-midnight"] {
|
||||
--console-theme-background: linear-gradient(135deg, #2a3b4c, rgb(42 59 76 / 0.4));
|
||||
@@ -1049,6 +1098,18 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Resource pages have a second fixed header row below the main header. */
|
||||
.application-settings-workspace > .application-settings-navigation {
|
||||
top: 4rem;
|
||||
max-height: calc(100dvh - 5rem);
|
||||
}
|
||||
|
||||
.application-settings-navigation.is-flush {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Field labels */
|
||||
@@ -1460,6 +1521,11 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
|
||||
background: var(--coollabs-fill);
|
||||
}
|
||||
|
||||
.data-table-row-active {
|
||||
background: var(--coollabs-fill);
|
||||
color: var(--coollabs-fg);
|
||||
}
|
||||
|
||||
.listbox-option-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
@@ -1542,9 +1608,20 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Empty states sit edge-to-edge in settings cards — no outer inset/padding. */
|
||||
.application-settings-section-body:has(> .empty-state:only-child) {
|
||||
padding: 0;
|
||||
/* Empty states inside cards keep the same inset as other card content. */
|
||||
.application-settings-section-body:has(> .empty-state:only-child),
|
||||
.application-settings-section-body.is-flush:has(> .empty-state:only-child) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: var(--coollabs-base);
|
||||
}
|
||||
|
||||
.loading-state-card {
|
||||
border: 1px dashed var(--coollabs-line);
|
||||
border-radius: 12px;
|
||||
background: var(--coollabs-base);
|
||||
}
|
||||
|
||||
.application-settings-section-body.is-flush .empty-state,
|
||||
@@ -1554,8 +1631,6 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
/* Match the layer-card body corners when the empty fills the body. */
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Centered process dialog (startup / activity logs) — full-height log body.
|
||||
|
||||
@@ -42,7 +42,28 @@ function createApplicationTerminalTheme(accent, colors = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function createSystemTerminalTheme() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
return createApplicationTerminalTheme('#8C8E9C');
|
||||
}
|
||||
|
||||
return createApplicationTerminalTheme('#52525b', {
|
||||
black: '#18181b',
|
||||
red: '#dc2626',
|
||||
green: '#15803d',
|
||||
yellow: '#a16207',
|
||||
blue: '#2563eb',
|
||||
magenta: '#9333ea',
|
||||
cyan: '#0e7490',
|
||||
white: '#52525b',
|
||||
brightBlack: '#71717a',
|
||||
brightWhite: '#18181b',
|
||||
foreground: '#18181b',
|
||||
});
|
||||
}
|
||||
|
||||
const applicationTerminalThemes = {
|
||||
'system': createSystemTerminalTheme(),
|
||||
'shadows-midnight': createApplicationTerminalTheme('#6d7a7c', {
|
||||
blue: '#7392ad',
|
||||
cyan: '#7fa3a6',
|
||||
@@ -155,14 +176,26 @@ export function initializeTerminalComponent() {
|
||||
scrollLockY: 0,
|
||||
scrollLockStyles: null,
|
||||
preventPageScrollHandler: null,
|
||||
themeObserver: null,
|
||||
terminalSessionStartedAt: null,
|
||||
terminalSessionRemainingSeconds: null,
|
||||
terminalSessionCountdownInterval: null,
|
||||
selectedTheme: applicationTerminalThemes[localStorage.getItem('coolify-console-theme')]
|
||||
? localStorage.getItem('coolify-console-theme')
|
||||
: 'shadows-cosmic-purple',
|
||||
: 'system',
|
||||
|
||||
init() {
|
||||
this.themeObserver = new MutationObserver(() => {
|
||||
if (this.selectedTheme === 'system') {
|
||||
applicationTerminalThemes.system = createSystemTerminalTheme();
|
||||
this.setTerminalTheme('system');
|
||||
}
|
||||
});
|
||||
this.themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
|
||||
// Recover if a previous portal build left the terminal on <body>.
|
||||
this.$nextTick(() => this.salvageStrayFullscreenNodes());
|
||||
|
||||
@@ -343,6 +376,10 @@ export function initializeTerminalComponent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (themeName === 'system') {
|
||||
applicationTerminalThemes.system = createSystemTerminalTheme();
|
||||
}
|
||||
|
||||
this.selectedTheme = themeName;
|
||||
localStorage.setItem('coolify-console-theme', themeName);
|
||||
|
||||
@@ -413,7 +450,7 @@ export function initializeTerminalComponent() {
|
||||
disableStdin: false,
|
||||
scrollback: 5000,
|
||||
theme: isApplicationConsole
|
||||
? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes['shadows-cosmic-purple']
|
||||
? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes.system
|
||||
: undefined
|
||||
});
|
||||
this.fitAddon = new FitAddon();
|
||||
@@ -747,6 +784,10 @@ export function initializeTerminalComponent() {
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.themeObserver?.disconnect();
|
||||
},
|
||||
|
||||
|
||||
sendTerminalInput(data) {
|
||||
if (!this.term || !this.terminalActive) {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
@props(['application', 'currentRoute', 'flush' => false])
|
||||
|
||||
@php
|
||||
$applicationRouteParameters = [
|
||||
'project_uuid' => $application->environment->project->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
];
|
||||
|
||||
$configurationMenuItems = [
|
||||
[
|
||||
'label' => 'General',
|
||||
'route' => 'project.application.configuration',
|
||||
'active' => $currentRoute === 'project.application.configuration',
|
||||
],
|
||||
[
|
||||
'label' => 'Domains',
|
||||
'route' => 'project.application.domains',
|
||||
'active' => $currentRoute === 'project.application.domains',
|
||||
],
|
||||
[
|
||||
'label' => 'Advanced',
|
||||
'route' => 'project.application.advanced',
|
||||
'active' => $currentRoute === 'project.application.advanced',
|
||||
],
|
||||
[
|
||||
'label' => 'Swarm',
|
||||
'route' => 'project.application.swarm',
|
||||
'active' => $currentRoute === 'project.application.swarm',
|
||||
'visible' => $application->destination->server->isSwarm(),
|
||||
],
|
||||
[
|
||||
'label' => 'Environment Variables',
|
||||
'route' => 'project.application.environment-variables',
|
||||
'active' => $currentRoute === 'project.application.environment-variables',
|
||||
],
|
||||
[
|
||||
'label' => 'Persistent Storage',
|
||||
'route' => 'project.application.persistent-storage',
|
||||
'active' => $currentRoute === 'project.application.persistent-storage',
|
||||
],
|
||||
[
|
||||
'label' => 'Backups',
|
||||
'route' => 'project.application.backup.index',
|
||||
'active' => str($currentRoute)->startsWith('project.application.backup'),
|
||||
],
|
||||
[
|
||||
'label' => 'Terminal',
|
||||
'route' => 'project.application.command',
|
||||
'active' => $currentRoute === 'project.application.command',
|
||||
'navigate' => false,
|
||||
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
|
||||
],
|
||||
[
|
||||
'label' => 'Deployment',
|
||||
'route' => 'project.application.deployment.index',
|
||||
'active' => str($currentRoute)->startsWith('project.application.deployment'),
|
||||
],
|
||||
[
|
||||
'label' => 'Runtime',
|
||||
'route' => 'project.application.logs',
|
||||
'active' => $currentRoute === 'project.application.logs',
|
||||
],
|
||||
[
|
||||
'label' => 'Git Source',
|
||||
'route' => 'project.application.source',
|
||||
'active' => $currentRoute === 'project.application.source',
|
||||
'visible' => $application->git_based(),
|
||||
],
|
||||
[
|
||||
'label' => 'Servers',
|
||||
'route' => 'project.application.servers',
|
||||
'active' => $currentRoute === 'project.application.servers',
|
||||
'badge' => true,
|
||||
],
|
||||
[
|
||||
'label' => 'Scheduled Tasks',
|
||||
'route' => 'project.application.scheduled-tasks.show',
|
||||
'active' => str($currentRoute)->startsWith('project.application.scheduled-tasks'),
|
||||
],
|
||||
[
|
||||
'label' => 'Webhooks',
|
||||
'route' => 'project.application.webhooks',
|
||||
'active' => $currentRoute === 'project.application.webhooks',
|
||||
],
|
||||
[
|
||||
'label' => 'Preview Deployments',
|
||||
'route' => 'project.application.preview-deployments',
|
||||
'active' => $currentRoute === 'project.application.preview-deployments',
|
||||
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
|
||||
],
|
||||
[
|
||||
'label' => 'Healthcheck',
|
||||
'route' => 'project.application.healthcheck',
|
||||
'active' => $currentRoute === 'project.application.healthcheck',
|
||||
'visible' => $application->build_pack !== 'dockercompose',
|
||||
],
|
||||
[
|
||||
'label' => 'Rollback',
|
||||
'route' => 'project.application.rollback',
|
||||
'active' => $currentRoute === 'project.application.rollback',
|
||||
],
|
||||
[
|
||||
'label' => 'Resource Limits',
|
||||
'route' => 'project.application.resource-limits',
|
||||
'active' => $currentRoute === 'project.application.resource-limits',
|
||||
],
|
||||
[
|
||||
'label' => 'Resource Operations',
|
||||
'route' => 'project.application.resource-operations',
|
||||
'active' => $currentRoute === 'project.application.resource-operations',
|
||||
],
|
||||
[
|
||||
'label' => 'Metrics',
|
||||
'route' => 'project.application.metrics',
|
||||
'active' => $currentRoute === 'project.application.metrics',
|
||||
],
|
||||
[
|
||||
'label' => 'Tags',
|
||||
'route' => 'project.application.tags',
|
||||
'active' => $currentRoute === 'project.application.tags',
|
||||
],
|
||||
[
|
||||
'label' => 'Danger Zone',
|
||||
'route' => 'project.application.danger',
|
||||
'active' => $currentRoute === 'project.application.danger',
|
||||
],
|
||||
];
|
||||
|
||||
$configurationMenuItems = array_values(array_filter(
|
||||
$configurationMenuItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
|
||||
// Icons follow the main sidebar's reicon set
|
||||
$menuIcons = [
|
||||
'General' => 'settings',
|
||||
'Domains' => 'globe',
|
||||
'Advanced' => 'grid',
|
||||
'Swarm' => 'destinations',
|
||||
'Environment Variables' => 'variables',
|
||||
'Persistent Storage' => 'storages',
|
||||
'Backups' => 'database',
|
||||
'Terminal' => 'browser-terminal',
|
||||
'Deployment' => 'time-back',
|
||||
'Runtime' => 'unordered-list',
|
||||
'Git Source' => 'sources',
|
||||
'Servers' => 'servers',
|
||||
'Scheduled Tasks' => 'calendar',
|
||||
'Webhooks' => 'notifications',
|
||||
'Preview Deployments' => 'eye',
|
||||
'Healthcheck' => 'feedback',
|
||||
'Rollback' => 'time-back',
|
||||
'Resource Limits' => 'cpu',
|
||||
'Resource Operations' => 'server-update',
|
||||
'Metrics' => 'graph',
|
||||
'Tags' => 'tags',
|
||||
'Danger Zone' => 'shield-alert',
|
||||
];
|
||||
|
||||
// Discord-style groups for the settings sidebar
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Advanced', 'Swarm', 'Environment Variables', 'Persistent Storage', 'Backups'],
|
||||
'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments'],
|
||||
'Logs' => ['Deployment', 'Runtime'],
|
||||
'Operations' => ['Terminal', 'Rollback', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
$groupedMenuItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => collect($configurationMenuItems)->whereIn('label', $labels)->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
|
||||
// In-page sections (cards) shown as sub-items under the active page
|
||||
$isComposeApp = $application->build_pack === 'dockercompose';
|
||||
$pageSections = [
|
||||
'project.application.configuration' => array_values(array_filter([
|
||||
['id' => 'application-details-section', 'label' => 'Application details'],
|
||||
['id' => 'public-access-section', 'label' => 'Public access'],
|
||||
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
|
||||
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
|
||||
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
|
||||
$isComposeApp ? null : ['id' => 'runtime-section', 'label' => 'Runtime'],
|
||||
$isComposeApp ? null : ['id' => 'security-section', 'label' => 'Security'],
|
||||
['id' => 'deployment-lifecycle-section', 'label' => 'Deployment lifecycle'],
|
||||
$isComposeApp ? null : ['id' => 'container-labels-section', 'label' => 'Container labels'],
|
||||
])),
|
||||
'project.application.advanced' => array_values(array_filter([
|
||||
['id' => 'advanced-build-section', 'label' => 'Build'],
|
||||
['id' => 'advanced-container-section', 'label' => 'Container'],
|
||||
$application->git_based() ? ['id' => 'advanced-deployment-section', 'label' => 'Deployment'] : null,
|
||||
$application->git_based() ? ['id' => 'advanced-git-section', 'label' => 'Git'] : null,
|
||||
$isComposeApp ? ['id' => 'advanced-compose-section', 'label' => 'Docker compose'] : null,
|
||||
['id' => 'advanced-proxy-section', 'label' => 'Proxy'],
|
||||
['id' => 'advanced-operations-section', 'label' => 'Operations'],
|
||||
['id' => 'advanced-logs-section', 'label' => 'Logs'],
|
||||
$isComposeApp ? null : ['id' => 'advanced-gpu-section', 'label' => 'GPU'],
|
||||
])),
|
||||
'project.application.webhooks' => [
|
||||
['id' => 'deploy-webhook-section', 'label' => 'Deploy webhook'],
|
||||
['id' => 'manual-git-webhooks-section', 'label' => 'Manual Git webhooks'],
|
||||
],
|
||||
'project.application.preview-deployments' => array_values(array_filter([
|
||||
['id' => 'preview-template-section', 'label' => 'URL template'],
|
||||
$application->is_github_based()
|
||||
? ['id' => 'preview-pull-requests-section', 'label' => 'Pull requests']
|
||||
: null,
|
||||
$application->build_pack === 'dockerimage'
|
||||
? ['id' => 'manual-preview-section', 'label' => 'Manual preview']
|
||||
: null,
|
||||
['id' => 'preview-deployments-section', 'label' => 'Deployments'],
|
||||
])),
|
||||
'project.application.healthcheck' => [
|
||||
['id' => 'healthcheck-configuration-section', 'label' => 'Configuration'],
|
||||
[
|
||||
'id' => $application->health_check_type === 'cmd'
|
||||
? 'healthcheck-command-section'
|
||||
: 'healthcheck-request-section',
|
||||
'label' => $application->health_check_type === 'cmd' ? 'Command' : 'HTTP request',
|
||||
],
|
||||
['id' => 'healthcheck-timing-section', 'label' => 'Timing and retries'],
|
||||
],
|
||||
'project.application.rollback' => [
|
||||
['id' => 'rollback-retention-section', 'label' => 'Image retention'],
|
||||
['id' => 'rollback-images-section', 'label' => 'Available images'],
|
||||
],
|
||||
'project.application.resource-limits' => [
|
||||
['id' => 'cpu-limits-section', 'label' => 'CPU'],
|
||||
['id' => 'memory-limits-section', 'label' => 'Memory'],
|
||||
],
|
||||
'project.application.resource-operations' => [
|
||||
['id' => 'clone-destination-section', 'label' => 'Clone destination'],
|
||||
['id' => 'clone-environment-section', 'label' => 'Clone environment'],
|
||||
['id' => 'move-resource-section', 'label' => 'Move resource'],
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<aside @class([
|
||||
'application-settings-navigation min-w-0 xl:self-start',
|
||||
'is-flush' => $flush,
|
||||
])>
|
||||
<nav aria-label="Configuration sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
|
||||
@unless ($loop->first)
|
||||
<div class="hidden xl:block my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
|
||||
@endunless
|
||||
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
|
||||
@foreach ($groupItems as $menuItem)
|
||||
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $applicationRouteParameters) }}">
|
||||
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
@if ($menuItem['badge'] ?? false)
|
||||
<span class="shrink-0">
|
||||
<livewire:project.application.server-status-badge :application="$application" />
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
@if ($menuItem['active'] && count($pageSections[$menuItem['route']] ?? []) >= 4)
|
||||
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
|
||||
x-data="{
|
||||
activeSection: '',
|
||||
scrollToSection(id) {
|
||||
this.activeSection = id;
|
||||
window.scrollToSettingsSection?.(id);
|
||||
},
|
||||
}">
|
||||
@foreach ($pageSections[$menuItem['route']] as $section)
|
||||
<button type="button" class="menu-subitem"
|
||||
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
|
||||
@click="scrollToSection('{{ $section['id'] }}')">
|
||||
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -6,17 +6,12 @@
|
||||
])
|
||||
|
||||
<section {{ $attributes->merge(['class' => 'application-settings-section']) }}>
|
||||
<header @class(['items-start!' => filled($description)])>
|
||||
<header @class(['items-start!' => filled($description ?? $helper)])>
|
||||
<div class="min-w-0 py-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>{{ $title }}</h3>
|
||||
@if ($helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
</div>
|
||||
@if (filled($description))
|
||||
<h3>{{ $title }}</h3>
|
||||
@if (filled($description ?? $helper))
|
||||
<p class="mt-0.5 text-xs leading-4 text-neutral-500 dark:text-[var(--coollabs-subtle)]">
|
||||
{{ $description }}
|
||||
{!! $description ?? $helper !!}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@props(['application', 'fullWidth' => false])
|
||||
|
||||
@php
|
||||
$hasLinks =
|
||||
(data_get($application, 'fqdn') ||
|
||||
@@ -8,21 +10,25 @@
|
||||
data_get($application, 'ports_mappings_array')) &&
|
||||
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true;
|
||||
|
||||
$linkItemClasses =
|
||||
'flex items-center gap-2 px-3 h-8 text-[13px] transition-colors text-neutral-600 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.06] hover:text-black dark:hover:text-fg';
|
||||
$linkItemClasses = 'listbox-option justify-start! gap-2.5!';
|
||||
@endphp
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<div @class(['relative', 'w-full' => $fullWidth]) x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false" title="Open application links"
|
||||
class="app-tab shrink-0 gap-1">
|
||||
Links
|
||||
<svg class="size-3.5 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
@class([
|
||||
'app-tab shrink-0 gap-1' => !$fullWidth,
|
||||
'button w-full justify-between' => $fullWidth,
|
||||
])>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
|
||||
Links
|
||||
</span>
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</span>
|
||||
</button>
|
||||
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
|
||||
class="absolute right-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised md:left-0 md:right-auto">
|
||||
<div x-show="open" x-cloak x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! min-w-60! max-w-96!" role="menu">
|
||||
@if ($hasLinks)
|
||||
@if (data_get($application, 'gitBrancLocation'))
|
||||
<a target="_blank" class="{{ $linkItemClasses }}" href="{{ $application->gitBranchLocation }}">
|
||||
@@ -35,7 +41,6 @@
|
||||
@if (data_get($fqdn, 'domain'))
|
||||
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
|
||||
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ getFqdnWithoutPort($domain) }}">
|
||||
<x-external-link class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($domain) }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@@ -45,7 +50,6 @@
|
||||
@if (data_get($application, 'fqdn'))
|
||||
@foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn)
|
||||
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ getFqdnWithoutPort($fqdn) }}">
|
||||
<x-external-link class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($fqdn) }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@@ -58,7 +62,6 @@
|
||||
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
|
||||
<a class="{{ $linkItemClasses }}" target="_blank"
|
||||
href="{{ getFqdnWithoutPort($domain) }}">
|
||||
<x-external-link class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">PR{{ data_get($preview, 'pull_request_id') }} | {{ getFqdnWithoutPort($domain) }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@@ -70,7 +73,6 @@
|
||||
@if (data_get($preview, 'fqdn'))
|
||||
<a class="{{ $linkItemClasses }}" target="_blank"
|
||||
href="{{ getFqdnWithoutPort(data_get($preview, 'fqdn')) }}">
|
||||
<x-external-link class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">PR{{ data_get($preview, 'pull_request_id') }} | {{ data_get($preview, 'fqdn') }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@@ -82,20 +84,17 @@
|
||||
@if ($application->destination->server->id === 0)
|
||||
<a class="{{ $linkItemClasses }}" target="_blank"
|
||||
href="http://localhost:{{ explode(':', $port)[0] }}">
|
||||
<x-external-link class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">Port {{ $port }}</span>
|
||||
</a>
|
||||
@else
|
||||
<a class="{{ $linkItemClasses }}" target="_blank"
|
||||
href="http://{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}">
|
||||
<x-external-link class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}</span>
|
||||
</a>
|
||||
@if (count($application->additional_servers) > 0)
|
||||
@foreach ($application->additional_servers as $server)
|
||||
<a class="{{ $linkItemClasses }}" target="_blank"
|
||||
href="http://{{ $server->ip }}:{{ explode(':', $port)[0] }}">
|
||||
<x-external-link class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ $server->ip }}:{{ explode(':', $port)[0] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@@ -104,7 +103,7 @@
|
||||
@endforeach
|
||||
@endif
|
||||
@else
|
||||
<div class="px-3 py-1.5 text-[13px] text-neutral-500 dark:text-fg-dim">No links available</div>
|
||||
<div class="listbox-option justify-start! cursor-default!">No links available</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
'danger' => 'project.service.volume-backups.danger',
|
||||
],
|
||||
default => [
|
||||
'back' => 'project.database.backup.index',
|
||||
'back' => 'project.database.configuration',
|
||||
'general' => 'project.database.backup.execution',
|
||||
's3' => 'project.database.backup.s3',
|
||||
'retention' => 'project.database.backup.retention',
|
||||
@@ -47,15 +47,19 @@
|
||||
['key' => 'executions', 'label' => 'Executions', 'icon' => 'browser-terminal'],
|
||||
['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'],
|
||||
];
|
||||
$backLabel = $context === 'database' ? 'Back to database' : 'Back to backups';
|
||||
$backParameters = $context === 'database'
|
||||
? collect($parameters)->except('backup_uuid')->all()
|
||||
: $parameters;
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Backup settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Backup</div>
|
||||
<a class="menu-item" {{ wireNavigate() }} href="{{ route($routes['back'], $parameters) }}">
|
||||
<a class="menu-item" {{ wireNavigate() }} href="{{ route($routes['back'], $backParameters) }}">
|
||||
<x-reicon name="logout" class="menu-item-icon rotate-180" />
|
||||
<span class="menu-item-label">Back to backups</span>
|
||||
<span class="menu-item-label">{{ $backLabel }}</span>
|
||||
</a>
|
||||
|
||||
@foreach ($items as $item)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
@props(['database', 'currentRoute'])
|
||||
|
||||
@php
|
||||
$databaseRouteParameters = [
|
||||
'project_uuid' => $database->environment->project->uuid,
|
||||
'environment_uuid' => $database->environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
];
|
||||
|
||||
$configurationItems = collect([
|
||||
['label' => 'General', 'route' => 'project.database.configuration', 'icon' => 'settings'],
|
||||
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'],
|
||||
['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()],
|
||||
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
|
||||
['label' => 'Runtime', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'visible' => auth()->user()?->can('update', $database)],
|
||||
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
|
||||
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
|
||||
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'],
|
||||
['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'icon' => 'server-update'],
|
||||
['label' => 'Metrics', 'route' => 'project.database.metrics', 'icon' => 'graph'],
|
||||
['label' => 'Tags', 'route' => 'project.database.tags', 'icon' => 'tags'],
|
||||
['label' => 'Danger Zone', 'route' => 'project.database.danger', 'icon' => 'shield-alert'],
|
||||
])->filter(fn (array $item): bool => $item['visible'] ?? true)
|
||||
->map(fn (array $item): array => [
|
||||
...$item,
|
||||
'active' => $currentRoute === $item['route']
|
||||
|| ($item['route'] === 'project.database.backup.index'
|
||||
&& str($currentRoute)->startsWith('project.database.backup')),
|
||||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Servers', 'Import Backup'],
|
||||
'Automation' => ['Webhooks', 'Healthcheck'],
|
||||
'Logs' => ['Runtime'],
|
||||
'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
|
||||
$groupedItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Database settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedItems as $groupLabel => $groupItems)
|
||||
@unless ($loop->first)
|
||||
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
|
||||
@endunless
|
||||
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
|
||||
@foreach ($groupItems as $menuItem)
|
||||
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $databaseRouteParameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -51,7 +51,7 @@
|
||||
{{-- Empty state: dashed card, icon badge, title, description, optional actions. --}}
|
||||
<div
|
||||
{{ $attributes->merge([
|
||||
'class' => "empty-state flex w-full flex-col items-center justify-center rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-6 py-10 text-center dark:border-white/[0.1] dark:bg-white/[0.02] {$minHeight}",
|
||||
'class' => "empty-state flex w-full flex-col items-center justify-center rounded-xl border border-dashed border-neutral-300 px-6 py-10 text-center dark:border-white/[0.1] {$minHeight}",
|
||||
]) }}>
|
||||
@if ($hasIcon)
|
||||
<div
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
}, 300)"
|
||||
@mouseleave="clearTimeout(_t); visible = false">
|
||||
@endif
|
||||
<button @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass, 'type' => 'button'])->merge($loadingAttributes) }}
|
||||
<button @disabled($disabled) @if ($isHighlighted) isHighlighted @endif
|
||||
{{ $attributes->merge(['class' => $defaultClass, 'type' => 'button'])->merge($loadingAttributes) }}
|
||||
@isset($confirm)
|
||||
x-on:click="toggleConfirmModal('{{ $confirm }}', '{{ explode('(', $confirmAction)[0] }}')"
|
||||
@endisset
|
||||
|
||||
@@ -233,15 +233,14 @@
|
||||
@endif
|
||||
|
||||
{{-- Dropdown for suggestions --}}
|
||||
<div x-show="showDropdown"
|
||||
x-transition
|
||||
class="absolute z-[60] w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg">
|
||||
<div x-show="showDropdown" x-cloak x-transition.origin.top
|
||||
class="listbox-panel top-full! z-[60]! mt-1! w-full! min-w-0! max-w-full!" role="listbox">
|
||||
|
||||
<template x-if="suggestions.length === 0 && currentScope">
|
||||
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<div class="px-2 py-2 text-sm text-neutral-500 dark:text-fg-dim">
|
||||
<div>No shared variables found in <span class="font-semibold" x-text="currentScope"></span> scope.</div>
|
||||
<a :href="getScopeUrl(currentScope)"
|
||||
class="text-coollabs dark:text-warning hover:underline text-xs mt-1 inline-block"
|
||||
class="mt-1 inline-block text-xs text-coollabs hover:underline dark:text-warning"
|
||||
target="_blank">
|
||||
Add <span x-text="currentScope"></span> variables →
|
||||
</a>
|
||||
@@ -250,24 +249,25 @@
|
||||
|
||||
<div x-show="suggestions.length > 0"
|
||||
x-ref="dropdownList"
|
||||
class="max-h-48 overflow-y-scroll"
|
||||
class="max-h-48 overflow-y-auto"
|
||||
style="scrollbar-width: thin;">
|
||||
<template x-for="(suggestion, index) in suggestions" :key="index">
|
||||
<div :id="'suggestion-' + index"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-2"
|
||||
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': index === selectedIndex }">
|
||||
class="listbox-option justify-start! gap-2.5!"
|
||||
:class="{ 'bg-neutral-100 dark:bg-white/[0.08]': index === selectedIndex }"
|
||||
role="option" :aria-selected="index === selectedIndex">
|
||||
<template x-if="suggestion.type === 'scope'">
|
||||
<span class="text-xs px-2 py-0.5 bg-coollabs/10 dark:bg-warning/10 text-coollabs dark:text-warning rounded">
|
||||
<span class="rounded-md border border-warning/25 bg-warning/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-warning">
|
||||
SCOPE
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="suggestion.type === 'variable'">
|
||||
<span class="text-xs px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded">
|
||||
<span class="rounded-md border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
VAR
|
||||
</span>
|
||||
</template>
|
||||
<span class="text-sm font-mono" x-text="suggestion.display"></span>
|
||||
<span class="min-w-0 truncate font-mono text-sm" x-text="suggestion.display"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
@props(['text' => null])
|
||||
<div class="inline-flex items-center justify-center" {{ $attributes }}>
|
||||
@if (isset($text))
|
||||
<div>{{ $text }}</div>
|
||||
@endif
|
||||
<svg class="w-4 h-4 mx-1 ml-3 text-coollabs dark:text-warning 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>
|
||||
|
||||
<div {{ $attributes->merge(['class' => 'inline-flex items-center justify-center gap-2 text-[13px] text-neutral-500 dark:text-fg-dim']) }}
|
||||
role="status" aria-live="polite">
|
||||
<svg class="size-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle class="opacity-20" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
|
||||
<path class="opacity-80" d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" />
|
||||
</svg>
|
||||
|
||||
@if (isset($text))
|
||||
<span>{{ $text }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
toggleMethod: @js($toggleMethod),
|
||||
testMethod: @js($testMethod),
|
||||
}">
|
||||
<x-forms.button type="button" :disabled="!$canUpdate"
|
||||
<x-forms.button type="button" :disabled="!$canUpdate" :isHighlighted="!$enabled"
|
||||
x-on:click="
|
||||
if (!enabled && !$el.closest('form').reportValidity()) return;
|
||||
$wire.$set(enabledProperty, !enabled).then(() => $wire.$call(toggleMethod));
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
@props(['service', 'currentRoute'])
|
||||
|
||||
@php
|
||||
$serviceRouteParameters = [
|
||||
'project_uuid' => $service->environment->project->uuid,
|
||||
'environment_uuid' => $service->environment->uuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
];
|
||||
|
||||
$configurationItems = collect([
|
||||
['label' => 'General', 'route' => 'project.service.configuration', 'icon' => 'settings'],
|
||||
['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'],
|
||||
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables'],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
|
||||
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
|
||||
['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
|
||||
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
|
||||
['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'],
|
||||
['label' => 'Tags', 'route' => 'project.service.tags', 'icon' => 'tags'],
|
||||
['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'shield-alert'],
|
||||
])->filter(fn (array $item): bool => $item['visible'] ?? true)
|
||||
->map(fn (array $item): array => [
|
||||
...$item,
|
||||
'active' => $currentRoute === $item['route']
|
||||
|| ($item['route'] === 'project.service.scheduled-tasks.show'
|
||||
&& str($currentRoute)->startsWith('project.service.scheduled-tasks'))
|
||||
|| ($item['route'] === 'project.service.volume-backups.index'
|
||||
&& str($currentRoute)->startsWith('project.service.volume-backups')),
|
||||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks'],
|
||||
'Logs' => ['Runtime'],
|
||||
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
|
||||
$groupedItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Service settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedItems as $groupLabel => $groupItems)
|
||||
@unless ($loop->first)
|
||||
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
|
||||
@endunless
|
||||
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
|
||||
@foreach ($groupItems as $menuItem)
|
||||
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false" title="Open service links"
|
||||
class="app-tab shrink-0 gap-1">
|
||||
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
|
||||
Links
|
||||
<svg class="size-3.5 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
|
||||
@@ -17,7 +18,6 @@
|
||||
class="absolute right-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised md:left-0 md:right-auto">
|
||||
@foreach ($links as $link)
|
||||
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ $link }}">
|
||||
<x-external-link class="size-3.5 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ $link }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
@@ -2,13 +2,4 @@
|
||||
'status' => 'Stopped',
|
||||
'noLoading' => false,
|
||||
])
|
||||
@php
|
||||
if (str($status)->contains('(')) {
|
||||
$displayStatus = str($status)->before('(')->trim()->headline()->value();
|
||||
} elseif (str($status)->contains(':')) {
|
||||
$displayStatus = str(explode(':', $status)[0])->headline()->value();
|
||||
} else {
|
||||
$displayStatus = str($status)->headline()->value();
|
||||
}
|
||||
@endphp
|
||||
<x-status-badge status="{{ $displayStatus }}" type="error" />
|
||||
<x-status-badge status="Stopped" type="neutral" />
|
||||
|
||||
@@ -4,8 +4,23 @@
|
||||
$userEmail = $user?->email ?? '';
|
||||
$userInitial = strtoupper(mb_substr($user?->name ?: ($user?->email ?: 'A'), 0, 1));
|
||||
@endphp
|
||||
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false"
|
||||
<div class="relative" x-data="{
|
||||
open: false,
|
||||
appearanceOpen: false,
|
||||
theme: localStorage.getItem('theme') || 'dark',
|
||||
setTheme(type) {
|
||||
this.theme = type;
|
||||
localStorage.setItem('theme', type);
|
||||
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = type === 'dark' || (type === 'system' && prefersDark);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
|
||||
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
|
||||
},
|
||||
}" @keydown.escape.window="open = false; appearanceOpen = false"
|
||||
@click.outside="open = false; appearanceOpen = false">
|
||||
<button type="button" @click="open = !open"
|
||||
title="{{ $userName }}" aria-label="Account menu for {{ $userName }}"
|
||||
class="flex h-8 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 shadow-sm transition-colors hover:bg-neutral-200 dark:border-white/[0.08] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]">
|
||||
<span
|
||||
@@ -32,12 +47,35 @@
|
||||
Profile
|
||||
</span>
|
||||
</a>
|
||||
<a href="{{ route('profile.appearance') }}" {{ wireNavigate() }} class="listbox-option">
|
||||
<button type="button" class="listbox-option w-full" @click="appearanceOpen = !appearanceOpen"
|
||||
:aria-expanded="appearanceOpen">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-reicon name="settings" class="size-4 opacity-80" />
|
||||
Appearance
|
||||
</span>
|
||||
</a>
|
||||
<svg class="size-3.5 text-neutral-400 transition-transform dark:text-fg-faint"
|
||||
:class="appearanceOpen && 'rotate-180'" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-show="appearanceOpen" x-collapse class="mx-1 grid gap-0.5 pb-1 pl-6">
|
||||
@foreach ([
|
||||
['value' => 'light', 'label' => 'Light'],
|
||||
['value' => 'system', 'label' => 'System'],
|
||||
['value' => 'dark', 'label' => 'Dark'],
|
||||
] as $option)
|
||||
<button type="button" @click="setTheme('{{ $option['value'] }}')"
|
||||
class="flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<span>{{ $option['label'] }}</span>
|
||||
<svg x-show="theme === '{{ $option['value'] }}'" class="size-3.5 text-coollabs dark:text-warning"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="my-1 h-px bg-neutral-200 dark:bg-white/[0.07]"></div>
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
|
||||
{{-- ============ MAIN ============ --}}
|
||||
<main
|
||||
class="min-h-screen bg-white dark:bg-panel lg:border-l border-neutral-200 dark:border-white/[0.06] px-5 py-6 sm:px-8 lg:px-10 lg:pt-[calc(3rem+1.75rem)] lg:pb-10"
|
||||
class="min-h-screen bg-white dark:bg-panel px-5 py-6 sm:px-8 lg:px-10 lg:pt-[calc(3rem+1.75rem)] lg:pb-10"
|
||||
:class="[collapsed ? 'lg:ml-16' : 'lg:ml-56', sidebarReady ? 'transition-[margin] duration-200' : '']">
|
||||
<div class="mx-auto w-full max-w-[1400px]">
|
||||
{{ $slot }}
|
||||
|
||||
@@ -50,174 +50,184 @@
|
||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||
<livewire:project.application.heading :application="$application" wire:key="application-heading-backup-index" />
|
||||
|
||||
<div class="application-settings-form flex flex-col gap-6">
|
||||
<x-application.settings-section title="Storage backups"
|
||||
helper="Schedule backups for persistent volumes and directory mounts attached to this application.">
|
||||
@can('update', $application)
|
||||
<x-slot:actions>
|
||||
<x-modal-input title="New scheduled backup" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<button type="button"
|
||||
class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
Add
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:project.application.backup.create :application="$application"
|
||||
wire:key="create-volume-backup-{{ $application->id }}" />
|
||||
</x-modal-input>
|
||||
</x-slot:actions>
|
||||
@endcan
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-application.configuration-sidebar :application="$application"
|
||||
current-route="project.application.backup.index" />
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Schedules</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backups->count() }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Enabled</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backups->where('enabled', true)->count() }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Total executions</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backups->sum('executions_count') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
<div class="application-settings-form flex min-w-0 flex-col gap-6">
|
||||
<x-application.settings-section title="Storage backups"
|
||||
helper="Schedule backups for persistent volumes and directory mounts attached to this application.">
|
||||
@can('update', $application)
|
||||
<x-slot:actions>
|
||||
<x-modal-input title="New scheduled backup" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<button type="button"
|
||||
class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
Add
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:project.application.backup.create :application="$application"
|
||||
wire:key="create-volume-backup-{{ $application->id }}" />
|
||||
</x-modal-input>
|
||||
</x-slot:actions>
|
||||
@endcan
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="relative w-full sm:max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input type="search" x-model="search" placeholder="Search backups" aria-label="Search backups"
|
||||
class="input h-8! w-full py-0! pr-8! pl-8!" />
|
||||
<button x-cloak x-show="search" x-on:click="search = ''" type="button"
|
||||
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||||
aria-label="Clear search">
|
||||
<span class="text-sm leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative" x-on:click.outside="filterOpen = false">
|
||||
<button type="button" class="button" x-on:click="filterOpen = !filterOpen; sortOpen = false">
|
||||
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" />
|
||||
</svg>
|
||||
Filter
|
||||
</button>
|
||||
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
|
||||
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in filterOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="typeFilter = option.value; filterOpen = false">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="typeFilter === option.value" class="size-3.5 text-warning"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Schedules</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backups->count() }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Enabled</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backups->where('enabled', true)->count() }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Total executions</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backups->sum('executions_count') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<div class="relative" x-on:click.outside="sortOpen = false">
|
||||
<button type="button" class="button" x-on:click="sortOpen = !sortOpen; filterOpen = false">
|
||||
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3" stroke="currentColor"
|
||||
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
Sort
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="relative w-full sm:max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input type="search" x-model="search" placeholder="Search backups" aria-label="Search backups"
|
||||
class="input h-8! w-full py-0! pr-8! pl-8!" />
|
||||
<button x-cloak x-show="search" x-on:click="search = ''" type="button"
|
||||
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||||
aria-label="Clear search">
|
||||
<span class="text-sm leading-none">×</span>
|
||||
</button>
|
||||
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
|
||||
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in sortOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="sortBy = option.value; sortOpen = false">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative" x-on:click.outside="filterOpen = false">
|
||||
<button type="button" class="button" x-on:click="filterOpen = !filterOpen; sortOpen = false">
|
||||
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" />
|
||||
</svg>
|
||||
Filter
|
||||
</button>
|
||||
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
|
||||
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in filterOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="typeFilter = option.value; filterOpen = false">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="typeFilter === option.value" class="size-3.5 text-warning"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" x-on:click.outside="sortOpen = false">
|
||||
<button type="button" class="button" x-on:click="sortOpen = !sortOpen; filterOpen = false">
|
||||
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3" stroke="currentColor"
|
||||
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
Sort
|
||||
</button>
|
||||
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
|
||||
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in sortOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="sortBy = option.value; sortOpen = false">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div @class([
|
||||
'application-settings-section-body w-full',
|
||||
'is-flush' => $backups->isNotEmpty(),
|
||||
])>
|
||||
<div x-cloak x-show="backups.length > 0 && filteredBackups.length === 0">
|
||||
<x-empty size="sm" title="No backups found"
|
||||
description="No scheduled backups match your search." />
|
||||
</div>
|
||||
|
||||
@if ($backups->isNotEmpty())
|
||||
<div class="data-table flex w-full flex-col" x-show="filteredBackups.length > 0">
|
||||
<div class="data-table-header backup-table-grid">
|
||||
<span>Target</span>
|
||||
<span>Type</span>
|
||||
<span>Schedule</span>
|
||||
<span>Status</span>
|
||||
<span>Last run</span>
|
||||
<span class="text-right">Executions</span>
|
||||
</div>
|
||||
|
||||
@foreach ($backups as $backup)
|
||||
@php
|
||||
$latestExecution = $backup->latestExecution;
|
||||
$status = $latestExecution?->status;
|
||||
$statusLabel = match ($status) {
|
||||
'running' => 'In progress',
|
||||
'success' => 'Success',
|
||||
'failed' => 'Failed',
|
||||
default => $backup->enabled ? 'Waiting' : 'Disabled',
|
||||
};
|
||||
$statusType = match ($status) {
|
||||
'running' => 'warning',
|
||||
'success' => 'success',
|
||||
'failed' => 'error',
|
||||
default => 'neutral',
|
||||
};
|
||||
@endphp
|
||||
<a wire:key="volume-backup-{{ $backup->uuid }}"
|
||||
x-show="isVisible(@js((string) $backup->id))"
|
||||
x-bind:style="{ order: backupOrder(@js((string) $backup->id)) }"
|
||||
href="{{ route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}"
|
||||
{{ wireNavigate() }}
|
||||
class="data-table-row backup-table-grid text-[13px] text-neutral-700 dark:text-fg-dim">
|
||||
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $backup->targetName() }}">
|
||||
{{ $backup->targetName() }}
|
||||
</span>
|
||||
<span>{{ $backup->targetType() }}</span>
|
||||
<span>{{ $backup->frequency }}</span>
|
||||
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
|
||||
<span>
|
||||
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
|
||||
</span>
|
||||
<span class="text-right tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backup->executions_count }}
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<x-empty size="sm" title="No scheduled backups"
|
||||
description="Add a persistent volume or directory backup schedule to protect application data."
|
||||
icon-name="storages" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="application-settings-section-body is-flush w-full">
|
||||
<div x-cloak x-show="backups.length > 0 && filteredBackups.length === 0">
|
||||
<x-empty size="sm" title="No backups found"
|
||||
description="No scheduled backups match your search." />
|
||||
</div>
|
||||
|
||||
@if ($backups->isNotEmpty())
|
||||
<div class="data-table flex w-full flex-col" x-show="filteredBackups.length > 0">
|
||||
<div class="data-table-header backup-table-grid">
|
||||
<span>Target</span>
|
||||
<span>Type</span>
|
||||
<span>Schedule</span>
|
||||
<span>Status</span>
|
||||
<span>Last run</span>
|
||||
<span class="text-right">Executions</span>
|
||||
</div>
|
||||
|
||||
@foreach ($backups as $backup)
|
||||
@php
|
||||
$latestExecution = $backup->latestExecution;
|
||||
$status = $latestExecution?->status;
|
||||
$statusLabel = match ($status) {
|
||||
'running' => 'In progress',
|
||||
'success' => 'Success',
|
||||
'failed' => 'Failed',
|
||||
default => $backup->enabled ? 'Waiting' : 'Disabled',
|
||||
};
|
||||
$statusType = match ($status) {
|
||||
'running' => 'warning',
|
||||
'success' => 'success',
|
||||
'failed' => 'error',
|
||||
default => 'neutral',
|
||||
};
|
||||
@endphp
|
||||
<a wire:key="volume-backup-{{ $backup->uuid }}"
|
||||
x-show="isVisible(@js((string) $backup->id))"
|
||||
x-bind:style="{ order: backupOrder(@js((string) $backup->id)) }"
|
||||
href="{{ route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}"
|
||||
{{ wireNavigate() }}
|
||||
class="data-table-row backup-table-grid text-[13px] text-neutral-700 dark:text-fg-dim">
|
||||
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $backup->targetName() }}">
|
||||
{{ $backup->targetName() }}
|
||||
</span>
|
||||
<span>{{ $backup->targetType() }}</span>
|
||||
<span>{{ $backup->frequency }}</span>
|
||||
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
|
||||
<span>
|
||||
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
|
||||
</span>
|
||||
<span class="text-right tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backup->executions_count }}
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<x-empty size="sm" title="No scheduled backups"
|
||||
description="Add a persistent volume or directory backup schedule to protect application data."
|
||||
icon-name="storages" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -5,260 +5,9 @@
|
||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||
<livewire:project.application.heading :application="$application" :wire:key="'application-heading-'.$currentRoute" />
|
||||
|
||||
@php
|
||||
$applicationRouteParameters = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
];
|
||||
|
||||
$configurationMenuItems = [
|
||||
[
|
||||
'label' => 'General',
|
||||
'route' => 'project.application.configuration',
|
||||
'active' => $currentRoute === 'project.application.configuration',
|
||||
],
|
||||
[
|
||||
'label' => 'Domains',
|
||||
'route' => 'project.application.domains',
|
||||
'active' => $currentRoute === 'project.application.domains',
|
||||
],
|
||||
[
|
||||
'label' => 'Advanced',
|
||||
'route' => 'project.application.advanced',
|
||||
'active' => $currentRoute === 'project.application.advanced',
|
||||
],
|
||||
[
|
||||
'label' => 'Swarm',
|
||||
'route' => 'project.application.swarm',
|
||||
'active' => $currentRoute === 'project.application.swarm',
|
||||
'visible' => $application->destination->server->isSwarm(),
|
||||
],
|
||||
[
|
||||
'label' => 'Environment Variables',
|
||||
'route' => 'project.application.environment-variables',
|
||||
'active' => $currentRoute === 'project.application.environment-variables',
|
||||
],
|
||||
[
|
||||
'label' => 'Persistent Storage',
|
||||
'route' => 'project.application.persistent-storage',
|
||||
'active' => $currentRoute === 'project.application.persistent-storage',
|
||||
],
|
||||
[
|
||||
'label' => 'Git Source',
|
||||
'route' => 'project.application.source',
|
||||
'active' => $currentRoute === 'project.application.source',
|
||||
'visible' => $application->git_based(),
|
||||
],
|
||||
[
|
||||
'label' => 'Servers',
|
||||
'route' => 'project.application.servers',
|
||||
'active' => $currentRoute === 'project.application.servers',
|
||||
'badge' => true,
|
||||
],
|
||||
[
|
||||
'label' => 'Scheduled Tasks',
|
||||
'route' => 'project.application.scheduled-tasks.show',
|
||||
'active' => str($currentRoute)->startsWith('project.application.scheduled-tasks'),
|
||||
],
|
||||
[
|
||||
'label' => 'Webhooks',
|
||||
'route' => 'project.application.webhooks',
|
||||
'active' => $currentRoute === 'project.application.webhooks',
|
||||
],
|
||||
[
|
||||
'label' => 'Preview Deployments',
|
||||
'route' => 'project.application.preview-deployments',
|
||||
'active' => $currentRoute === 'project.application.preview-deployments',
|
||||
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
|
||||
],
|
||||
[
|
||||
'label' => 'Healthcheck',
|
||||
'route' => 'project.application.healthcheck',
|
||||
'active' => $currentRoute === 'project.application.healthcheck',
|
||||
'visible' => $application->build_pack !== 'dockercompose',
|
||||
],
|
||||
[
|
||||
'label' => 'Rollback',
|
||||
'route' => 'project.application.rollback',
|
||||
'active' => $currentRoute === 'project.application.rollback',
|
||||
],
|
||||
[
|
||||
'label' => 'Resource Limits',
|
||||
'route' => 'project.application.resource-limits',
|
||||
'active' => $currentRoute === 'project.application.resource-limits',
|
||||
],
|
||||
[
|
||||
'label' => 'Resource Operations',
|
||||
'route' => 'project.application.resource-operations',
|
||||
'active' => $currentRoute === 'project.application.resource-operations',
|
||||
],
|
||||
[
|
||||
'label' => 'Metrics',
|
||||
'route' => 'project.application.metrics',
|
||||
'active' => $currentRoute === 'project.application.metrics',
|
||||
],
|
||||
[
|
||||
'label' => 'Tags',
|
||||
'route' => 'project.application.tags',
|
||||
'active' => $currentRoute === 'project.application.tags',
|
||||
],
|
||||
[
|
||||
'label' => 'Danger Zone',
|
||||
'route' => 'project.application.danger',
|
||||
'active' => $currentRoute === 'project.application.danger',
|
||||
],
|
||||
];
|
||||
|
||||
$configurationMenuItems = array_values(array_filter(
|
||||
$configurationMenuItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
|
||||
// Icons follow the main sidebar's reicon set
|
||||
$menuIcons = [
|
||||
'General' => 'settings',
|
||||
'Domains' => 'globe',
|
||||
'Advanced' => 'grid',
|
||||
'Swarm' => 'destinations',
|
||||
'Environment Variables' => 'variables',
|
||||
'Persistent Storage' => 'storages',
|
||||
'Git Source' => 'sources',
|
||||
'Servers' => 'servers',
|
||||
'Scheduled Tasks' => 'calendar',
|
||||
'Webhooks' => 'notifications',
|
||||
'Preview Deployments' => 'eye',
|
||||
'Healthcheck' => 'feedback',
|
||||
'Rollback' => 'time-back',
|
||||
'Resource Limits' => 'cpu',
|
||||
'Resource Operations' => 'server-update',
|
||||
'Metrics' => 'graph',
|
||||
'Tags' => 'tags',
|
||||
'Danger Zone' => 'shield-alert',
|
||||
];
|
||||
|
||||
// Discord-style groups for the settings sidebar
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Advanced', 'Swarm', 'Environment Variables', 'Persistent Storage'],
|
||||
'Build & deploy' => ['Git Source', 'Servers', 'Preview Deployments', 'Rollback', 'Scheduled Tasks', 'Webhooks', 'Healthcheck'],
|
||||
'Operations' => ['Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
$groupedMenuItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => collect($configurationMenuItems)->whereIn('label', $labels)->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
|
||||
// In-page sections (cards) shown as sub-items under the active page
|
||||
$isComposeApp = $application->build_pack === 'dockercompose';
|
||||
$pageSections = [
|
||||
'project.application.configuration' => array_values(array_filter([
|
||||
['id' => 'application-details-section', 'label' => 'Application details'],
|
||||
['id' => 'public-access-section', 'label' => 'Public access'],
|
||||
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
|
||||
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
|
||||
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
|
||||
$isComposeApp ? null : ['id' => 'runtime-section', 'label' => 'Runtime'],
|
||||
$isComposeApp ? null : ['id' => 'security-section', 'label' => 'Security'],
|
||||
['id' => 'deployment-lifecycle-section', 'label' => 'Deployment lifecycle'],
|
||||
$isComposeApp ? null : ['id' => 'container-labels-section', 'label' => 'Container labels'],
|
||||
])),
|
||||
'project.application.advanced' => array_values(array_filter([
|
||||
['id' => 'advanced-build-section', 'label' => 'Build'],
|
||||
['id' => 'advanced-container-section', 'label' => 'Container'],
|
||||
$application->git_based() ? ['id' => 'advanced-deployment-section', 'label' => 'Deployment'] : null,
|
||||
$application->git_based() ? ['id' => 'advanced-git-section', 'label' => 'Git'] : null,
|
||||
$isComposeApp ? ['id' => 'advanced-compose-section', 'label' => 'Docker compose'] : null,
|
||||
['id' => 'advanced-proxy-section', 'label' => 'Proxy'],
|
||||
['id' => 'advanced-operations-section', 'label' => 'Operations'],
|
||||
['id' => 'advanced-logs-section', 'label' => 'Logs'],
|
||||
$isComposeApp ? null : ['id' => 'advanced-gpu-section', 'label' => 'GPU'],
|
||||
])),
|
||||
'project.application.webhooks' => [
|
||||
['id' => 'deploy-webhook-section', 'label' => 'Deploy webhook'],
|
||||
['id' => 'manual-git-webhooks-section', 'label' => 'Manual Git webhooks'],
|
||||
],
|
||||
'project.application.preview-deployments' => array_values(array_filter([
|
||||
['id' => 'preview-template-section', 'label' => 'URL template'],
|
||||
$application->is_github_based()
|
||||
? ['id' => 'preview-pull-requests-section', 'label' => 'Pull requests']
|
||||
: null,
|
||||
$application->build_pack === 'dockerimage'
|
||||
? ['id' => 'manual-preview-section', 'label' => 'Manual preview']
|
||||
: null,
|
||||
['id' => 'preview-deployments-section', 'label' => 'Deployments'],
|
||||
])),
|
||||
'project.application.healthcheck' => [
|
||||
['id' => 'healthcheck-configuration-section', 'label' => 'Configuration'],
|
||||
[
|
||||
'id' => $application->health_check_type === 'cmd'
|
||||
? 'healthcheck-command-section'
|
||||
: 'healthcheck-request-section',
|
||||
'label' => $application->health_check_type === 'cmd' ? 'Command' : 'HTTP request',
|
||||
],
|
||||
['id' => 'healthcheck-timing-section', 'label' => 'Timing and retries'],
|
||||
],
|
||||
'project.application.rollback' => [
|
||||
['id' => 'rollback-retention-section', 'label' => 'Image retention'],
|
||||
['id' => 'rollback-images-section', 'label' => 'Available images'],
|
||||
],
|
||||
'project.application.resource-limits' => [
|
||||
['id' => 'cpu-limits-section', 'label' => 'CPU'],
|
||||
['id' => 'memory-limits-section', 'label' => 'Memory'],
|
||||
],
|
||||
'project.application.resource-operations' => [
|
||||
['id' => 'clone-resource-section', 'label' => 'Clone resource'],
|
||||
['id' => 'move-resource-section', 'label' => 'Move resource'],
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Configuration sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
|
||||
@unless ($loop->first)
|
||||
<div class="hidden xl:block my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
|
||||
@endunless
|
||||
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
|
||||
@foreach ($groupItems as $menuItem)
|
||||
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
])
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route($menuItem['route'], $applicationRouteParameters) }}">
|
||||
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
@if ($menuItem['badge'] ?? false)
|
||||
<span class="shrink-0">
|
||||
<livewire:project.application.server-status-badge :application="$application" />
|
||||
</span>
|
||||
@endif
|
||||
</a>
|
||||
@if ($menuItem['active'] && count($pageSections[$menuItem['route']] ?? []) >= 4)
|
||||
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
|
||||
x-data="{
|
||||
activeSection: '',
|
||||
scrollToSection(id) {
|
||||
this.activeSection = id;
|
||||
window.scrollToSettingsSection?.(id);
|
||||
},
|
||||
}">
|
||||
@foreach ($pageSections[$menuItem['route']] as $section)
|
||||
<button type="button" class="menu-subitem"
|
||||
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
|
||||
@click="scrollToSection('{{ $section['id'] }}')">
|
||||
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
<x-application.configuration-sidebar :application="$application" :current-route="$currentRoute" />
|
||||
|
||||
<div class="min-w-0">
|
||||
@if ($currentRoute === 'project.application.configuration')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<div>
|
||||
<x-slot:title>{{ data_get_str($application, 'name')->limit(10) }} > Deployments | Coolify</x-slot>
|
||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||
<livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-index" />
|
||||
@unless ($embedded)
|
||||
<x-slot:title>{{ data_get_str($application, 'name')->limit(10) }} > Deployments | Coolify</x-slot>
|
||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||
<livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-index" />
|
||||
@endunless
|
||||
|
||||
@php
|
||||
$lastPage = max(1, (int) ceil($deployments_count / $defaultTake));
|
||||
@@ -11,7 +13,20 @@
|
||||
$hasActiveQuery = trim($search) !== '' || $hasActiveFilter;
|
||||
@endphp
|
||||
|
||||
<div class="application-settings-form"
|
||||
<section @class([
|
||||
'application-settings-workspace w-full',
|
||||
'mt-4 max-w-[1180px] lg:mt-0' => ! $embedded,
|
||||
])>
|
||||
<div @class([
|
||||
'min-w-0',
|
||||
'grid gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10' => ! $embedded,
|
||||
])>
|
||||
@unless ($embedded)
|
||||
<x-application.configuration-sidebar :application="$application"
|
||||
current-route="project.application.deployment.index" />
|
||||
@endunless
|
||||
|
||||
<div class="application-settings-form min-w-0"
|
||||
@if (!$skip) wire:poll.5000ms="reloadDeployments" @endif>
|
||||
<x-application.settings-section title="Deployment history"
|
||||
helper="Search, filter, and open a deployment to inspect its build logs." flush>
|
||||
@@ -204,7 +219,10 @@
|
||||
<a wire:key="deployment-{{ data_get($deployment, 'deployment_uuid') }}"
|
||||
href="{{ $current_url . '/' . data_get($deployment, 'deployment_uuid') }}"
|
||||
{{ wireNavigate() }}
|
||||
class="data-table-row deployment-table-grid border-b border-neutral-200 text-[13px] text-neutral-600 dark:border-white/[0.08] dark:text-fg-dim">
|
||||
@class([
|
||||
'data-table-row deployment-table-grid border-b border-neutral-200 text-[13px] text-neutral-600 dark:border-white/[0.08] dark:text-fg-dim',
|
||||
'data-table-row-active' => $selectedDeploymentUuid === data_get($deployment, 'deployment_uuid'),
|
||||
])>
|
||||
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
|
||||
<span>{{ $sourceLabel }}</span>
|
||||
<span class="min-w-0">
|
||||
@@ -247,4 +265,6 @@
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<div class="flex h-[calc(100dvh-7.5rem)] min-h-[22rem] flex-col overflow-hidden sm:h-[calc(100vh-8rem)] sm:min-h-[32rem] lg:min-h-[42rem]">
|
||||
<div
|
||||
class="flex min-h-[calc(100dvh-7.5rem)] flex-col xl:h-[calc(100dvh-7.5rem)] xl:min-h-0 xl:overflow-hidden">
|
||||
<x-slot:title>
|
||||
{{ data_get_str($application, 'name')->limit(10) }} > Deployment | Coolify
|
||||
</x-slot>
|
||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||
<livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-show" />
|
||||
<div x-data="{
|
||||
<section class="application-settings-workspace flex min-h-0 w-full max-w-[1180px] flex-1">
|
||||
<div class="grid min-h-0 min-w-0 flex-1 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-application.configuration-sidebar :application="$application" :flush="true"
|
||||
current-route="project.application.deployment.show" />
|
||||
<div class="flex min-h-0 min-w-0 flex-col gap-4 xl:h-full xl:pt-px">
|
||||
<livewire:project.application.deployment.index :embedded="true"
|
||||
:selected-deployment-uuid="$deployment_uuid"
|
||||
wire:key="embedded-deployment-history-{{ $deployment_uuid }}" />
|
||||
|
||||
<div x-data="{
|
||||
fullscreen: @entangle('fullscreen'),
|
||||
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
|
||||
rafId: null,
|
||||
@@ -261,8 +271,8 @@
|
||||
this.deploymentFinishedCleanup = null;
|
||||
}
|
||||
}
|
||||
}" class="flex flex-1 min-h-0 flex-col overflow-hidden">
|
||||
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'mt-2 flex flex-1 min-h-0 flex-col overflow-hidden lg:mt-3'">
|
||||
}" class="flex h-[calc(100dvh-8rem)] min-h-[32rem] w-full flex-col overflow-hidden xl:h-auto xl:min-h-0 xl:flex-1">
|
||||
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'mt-2 flex flex-1 min-h-0 flex-col overflow-hidden lg:mt-0'">
|
||||
<div @if ($isKeepAliveOn) wire:poll.2000ms="polling" @endif
|
||||
class="logs-viewer flex min-h-0 w-full flex-col overflow-hidden bg-[#0d0d0d] text-neutral-100"
|
||||
:class="fullscreen ? 'h-full' : 'flex-1 rounded-xl border border-neutral-800 shadow-sm'">
|
||||
@@ -456,4 +466,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -9,16 +9,16 @@
|
||||
<x-unsaved-bar action="submit" />
|
||||
{{-- Temporarily hidden: the "Compose parser" dev hint and the "View details"
|
||||
resource-details modal trigger. --}}
|
||||
@if ($buildPack === 'dockercompose')
|
||||
<div class="mb-4 flex flex-wrap items-center justify-end gap-2">
|
||||
<x-forms.button canGate="update" :canResource="$application" wire:target='initLoadingCompose'
|
||||
x-on:click="$wire.dispatch('loadCompose', false)">
|
||||
{{ $application->docker_compose_raw ? 'Reload compose' : 'Load compose' }}
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
<div class="application-settings-grid flex flex-col gap-6">
|
||||
<x-application.settings-section id="application-details-section" title="Application details" helper="Name the application and choose the build strategy Coolify should use to deploy it." class="application-details-card">
|
||||
@if ($buildPack === 'dockercompose')
|
||||
<x-slot:actions>
|
||||
<x-forms.button canGate="update" :canResource="$application" wire:target='initLoadingCompose'
|
||||
x-on:click="$wire.dispatch('loadCompose', false)">
|
||||
{{ $application->docker_compose_raw ? 'Reload compose' : 'Load compose' }}
|
||||
</x-forms.button>
|
||||
</x-slot:actions>
|
||||
@endif
|
||||
<div class="grid gap-4">
|
||||
<x-forms.input x-bind:disabled="shouldDisable()" id="name" label="Name" required />
|
||||
<x-forms.input x-bind:disabled="shouldDisable()" id="description" label="Description" />
|
||||
@@ -302,7 +302,7 @@
|
||||
@endif
|
||||
@if ($buildPack === 'dockercompose')
|
||||
<div x-data="{ showRaw: true }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3>Docker Compose</h3>
|
||||
<x-forms.button x-show="{{ $application->settings->is_raw_compose_deployment_enabled ? 'false' : 'true' }}"
|
||||
@click.prevent="showRaw = !showRaw"
|
||||
|
||||
@@ -17,28 +17,6 @@
|
||||
'route' => 'project.application.configuration',
|
||||
'active' => $isSettingsRoute,
|
||||
],
|
||||
[
|
||||
'label' => 'Backups',
|
||||
'route' => 'project.application.backup.index',
|
||||
'active' => $routeIs('project.application.backup.*'),
|
||||
],
|
||||
[
|
||||
'label' => 'Console',
|
||||
'route' => 'project.application.command',
|
||||
'active' => $routeIs('project.application.command'),
|
||||
'navigate' => false,
|
||||
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
|
||||
],
|
||||
[
|
||||
'label' => 'Deployment Logs',
|
||||
'route' => 'project.application.deployment.index',
|
||||
'active' => $routeIs(['project.application.deployment.index', 'project.application.deployment.show']),
|
||||
],
|
||||
[
|
||||
'label' => 'Runtime Logs',
|
||||
'route' => 'project.application.logs',
|
||||
'active' => $routeIs('project.application.logs'),
|
||||
],
|
||||
];
|
||||
|
||||
$applicationMenuItems = array_values(array_filter(
|
||||
@@ -56,7 +34,7 @@
|
||||
};
|
||||
@endphp
|
||||
<div>
|
||||
<div class="mb-3 w-full lg:hidden">
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
{{ $application->name }}
|
||||
@@ -65,7 +43,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:hidden">
|
||||
<div class="w-full xl:hidden">
|
||||
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
|
||||
<div id="application-mobile-actions" class="relative mb-3"
|
||||
x-data="{ open: false }" @click.outside="open = false"
|
||||
@@ -179,25 +157,8 @@
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
{{-- Tabs may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
|
||||
<x-resource-heading-tabs class="min-w-0 flex-1">
|
||||
@foreach ($applicationMenuItems as $menuItem)
|
||||
<a @class([
|
||||
'app-tab shrink-0',
|
||||
'app-tab-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['active']) aria-current="page" @endif
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</x-resource-heading-tabs>
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-applications.links :application="$application" />
|
||||
</div>
|
||||
<div class="resource-heading-menus w-full">
|
||||
<x-applications.links :application="$application" full-width />
|
||||
</div>
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
|
||||
@@ -223,35 +184,11 @@
|
||||
</div>
|
||||
|
||||
{{-- Layer-2 top nav: one unified bar — menus left, actions right --}}
|
||||
<div class="hidden w-full items-center md:flex lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pl-2 lg:pr-4 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
|
||||
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
|
||||
<div
|
||||
class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto xl:border-0 xl:bg-transparent xl:p-0 xl:backdrop-blur-none xl:dark:bg-transparent">
|
||||
<div
|
||||
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
<div class="application-primary-tabs flex min-w-0 items-center gap-0.5">
|
||||
{{-- Tabs alone may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
|
||||
<x-resource-heading-tabs class="min-w-0">
|
||||
@foreach ($applicationMenuItems as $menuItem)
|
||||
<a wire:key="application-primary-nav-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'app-tab shrink-0',
|
||||
'app-tab-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['active']) aria-current="page" @endif
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
@if ($menuItem['label'] === 'Runtime Logs' && $application->restart_count > 0 && (!str($application->status)->startsWith('exited') || $application->stoppedAfterRestartLimit()))
|
||||
<span class="size-1.5 rounded-full bg-warning"
|
||||
title="Container has restarted {{ $application->restart_count }} time{{ $application->restart_count > 1 ? 's' : '' }}"></span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</x-resource-heading-tabs>
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-applications.links :application="$application" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
|
||||
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-start gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 xl:w-auto xl:justify-end dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
|
||||
{{-- Status badge temporarily hidden — will be redesigned later:
|
||||
<x-status.index :resource="$application" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" /> --}}
|
||||
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
|
||||
@@ -260,56 +197,90 @@
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
<x-applications.advanced :application="$application" />
|
||||
@endif
|
||||
@if (!str($application->status)->startsWith('exited'))
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
<x-forms.button canGate="deploy" :canResource="$application" title="With rolling update if possible" wire:click="deploy">
|
||||
<x-reicon name="refresh" class="size-4 text-orange-500 dark:text-orange-400" />
|
||||
Redeploy
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
@if ($application->destination->server->isSwarm())
|
||||
<x-forms.button canGate="deploy" :canResource="$application" title="Redeploy Swarm Service (rolling update)" wire:click="deploy">
|
||||
<x-reicon name="refresh" class="size-4 text-warning" />
|
||||
Update Service
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-modal-confirmation title="Confirm Application Restart?" buttonTitle="Restart"
|
||||
submitAction="restart" :actions="[
|
||||
'This application will be restarted without rebuilding.',
|
||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||
step2ButtonText="Confirm">
|
||||
<x-slot:content>
|
||||
<x-forms.button canGate="deploy" :canResource="$application" title="Restart without rebuilding">
|
||||
<x-reicon name="restart" class="size-4 text-warning" />
|
||||
Restart
|
||||
</x-forms.button>
|
||||
</x-slot:content>
|
||||
</x-modal-confirmation>
|
||||
@endif
|
||||
@endif
|
||||
<x-modal-confirmation :disabled="!auth()->user()->can('deploy', $application)" :authDisabled="!auth()->user()->can('deploy', $application)" title="Confirm Application Stopping?" buttonTitle="Stop"
|
||||
submitAction="stop" :checkboxes="$checkboxes" :actions="[
|
||||
'This application will be stopped.',
|
||||
'All non-persistent data of this application will be deleted.',
|
||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||
step1ButtonText="Continue" step2ButtonText="Confirm">
|
||||
<x-slot:button-title>
|
||||
<x-reicon name="stop" class="size-4 text-error" />
|
||||
Stop
|
||||
</x-slot:button-title>
|
||||
</x-modal-confirmation>
|
||||
@else
|
||||
<x-forms.button canGate="deploy" :canResource="$application" wire:click="deploy">
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-applications.links :application="$application" />
|
||||
</div>
|
||||
@if (str($application->status)->startsWith('exited'))
|
||||
<x-forms.button id="application-desktop-deploy" canGate="deploy" :canResource="$application"
|
||||
wire:click="deploy">
|
||||
<x-reicon name="play-circle" class="size-4 text-warning" />
|
||||
Deploy
|
||||
</x-forms.button>
|
||||
@else
|
||||
<div id="application-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 text-orange-500 dark:text-orange-400" />
|
||||
Redeploy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Redeploy
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
@if ($application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 text-warning" />
|
||||
Update Service
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Update Service
|
||||
</button>
|
||||
@endcan
|
||||
@else
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 text-warning" />
|
||||
Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
@endif
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="stop" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{-- Spacer: in-flow stand-in for the fixed layer-2 nav (h-12). Not visible content — keeps page body clear of the bar. --}}
|
||||
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -63,6 +63,11 @@
|
||||
icon-name="sources" />
|
||||
@endforelse
|
||||
</div>
|
||||
<div class="w-full" wire:loading wire:target="load_prs">
|
||||
<div class="flex min-h-32 items-center justify-center px-4 py-10">
|
||||
<x-loading text="Loading pull requests…" />
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div wire:target="loadImages" wire:loading>
|
||||
<div class="w-full" wire:target="loadImages" wire:loading>
|
||||
<div class="flex items-center justify-center gap-2 px-4 py-10 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
<x-loading class="size-4" />
|
||||
Loading available images…
|
||||
|
||||
@@ -29,15 +29,49 @@
|
||||
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0
|
||||
|| $backup->database_type === 'App\Models\StandaloneMysql'
|
||||
|| $backup->database_type === 'App\Models\StandaloneMariadb')
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="grid w-full gap-4">
|
||||
<x-forms.listbox id="dumpAll" label="Database selection" onChange="instantSave" :options="[
|
||||
['value' => true, 'label' => 'Back up all databases'],
|
||||
['value' => false, 'label' => 'Choose databases'],
|
||||
]" />
|
||||
@if (! $backup->dump_all)
|
||||
<x-forms.input label="Databases to back up"
|
||||
helper="Comma-separated database names. Leave empty to include the default database."
|
||||
id="databasesToBackup" />
|
||||
<div class="w-full" x-data="{
|
||||
value: @entangle('databasesToBackup').live,
|
||||
draft: '',
|
||||
get databases() {
|
||||
return (this.value || '').split(',').map(name => name.trim()).filter(Boolean);
|
||||
},
|
||||
addDatabase() {
|
||||
const names = this.draft.split(',').map(name => name.trim()).filter(Boolean);
|
||||
if (names.length === 0) return;
|
||||
this.value = [...new Set([...this.databases, ...names])].join(',');
|
||||
this.draft = '';
|
||||
},
|
||||
removeDatabase(index) {
|
||||
this.value = this.databases.filter((_, itemIndex) => itemIndex !== index).join(',');
|
||||
},
|
||||
}">
|
||||
<label class="mb-1.5 block text-sm font-medium">Databases to back up</label>
|
||||
<div class="chip-input">
|
||||
<template x-for="(database, index) in databases" :key="database">
|
||||
<span class="chip font-mono">
|
||||
<span x-text="database"></span>
|
||||
<button type="button" @click="removeDatabase(index)"
|
||||
class="chip-remove"
|
||||
:aria-label="`Remove ${database}`">
|
||||
<x-reicon name="x" class="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
<input x-model="draft" @keydown.enter.prevent="addDatabase()"
|
||||
@keydown="if ($event.key === ',') { $event.preventDefault(); addDatabase(); }"
|
||||
@blur="addDatabase()" type="text"
|
||||
placeholder="Type a database and press Enter" />
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-neutral-500 dark:text-fg-dim">
|
||||
Add one or more database names. Leave empty to include the default database.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($backup->database_type === 'App\Models\StandaloneMongodb')
|
||||
|
||||
@@ -5,22 +5,42 @@
|
||||
|
||||
<livewire:project.database.heading :database="$database" />
|
||||
|
||||
<div class="mt-8 w-full max-w-[1180px] lg:mt-3">
|
||||
<section class="application-settings-section">
|
||||
<div class="application-settings-section-header">
|
||||
<div>
|
||||
<h2>Scheduled backups</h2>
|
||||
<p>Automate database backups and track the latest execution for each schedule.</p>
|
||||
</div>
|
||||
<div class="application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-database.configuration-sidebar :database="$database" current-route="project.database.backup.index" />
|
||||
<div class="application-settings-form flex min-w-0 flex-col gap-6">
|
||||
<x-application.settings-section title="Database backups"
|
||||
description="Automate database backups and track the latest execution for each schedule.">
|
||||
@can('update', $database)
|
||||
<x-modal-input buttonTitle="+ Add" title="New Scheduled Backup" isHighlightedButton>
|
||||
<livewire:project.database.create-scheduled-backup :database="$database" />
|
||||
</x-modal-input>
|
||||
<x-slot:actions>
|
||||
<x-modal-input title="New Scheduled Backup" isHighlightedButton buttonTitle="+ Add">
|
||||
<livewire:project.database.create-scheduled-backup :database="$database" />
|
||||
</x-modal-input>
|
||||
</x-slot:actions>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="application-settings-section-body p-0!">
|
||||
<livewire:project.database.scheduled-backups :database="$database" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Schedules</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $database->scheduledBackups->count() }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Enabled</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $database->scheduledBackups->where('enabled', true)->count() }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Total executions</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $database->scheduledBackups->sum('executions_count') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<livewire:project.database.scheduled-backups :database="$database" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,76 +5,10 @@
|
||||
|
||||
<livewire:project.database.heading :database="$database" />
|
||||
|
||||
@php
|
||||
$databaseRouteParameters = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
];
|
||||
|
||||
$configurationItems = [
|
||||
['label' => 'General', 'route' => 'project.database.configuration', 'icon' => 'settings'],
|
||||
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'],
|
||||
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'],
|
||||
[
|
||||
'label' => 'Import Backup',
|
||||
'route' => 'project.database.import-backup',
|
||||
'icon' => 'upload',
|
||||
'visible' => auth()->user()?->can('update', $database),
|
||||
],
|
||||
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
|
||||
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
|
||||
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'],
|
||||
['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'icon' => 'server-update'],
|
||||
['label' => 'Metrics', 'route' => 'project.database.metrics', 'icon' => 'graph'],
|
||||
['label' => 'Tags', 'route' => 'project.database.tags', 'icon' => 'tags'],
|
||||
['label' => 'Danger Zone', 'route' => 'project.database.danger', 'icon' => 'shield-alert'],
|
||||
];
|
||||
|
||||
$configurationItems = collect($configurationItems)
|
||||
->filter(fn (array $item): bool => $item['visible'] ?? true)
|
||||
->map(fn (array $item): array => [
|
||||
...$item,
|
||||
'active' => $currentRoute === $item['route'],
|
||||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Environment Variables', 'Servers', 'Persistent Storage', 'Import Backup'],
|
||||
'Automation' => ['Webhooks', 'Healthcheck'],
|
||||
'Operations' => ['Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
|
||||
$groupedItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
@endphp
|
||||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Database settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedItems as $groupLabel => $groupItems)
|
||||
@unless ($loop->first)
|
||||
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
|
||||
aria-hidden="true"></div>
|
||||
@endunless
|
||||
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
|
||||
@foreach ($groupItems as $menuItem)
|
||||
<a @class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
])
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route($menuItem['route'], $databaseRouteParameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
<x-database.configuration-sidebar :database="$database" :current-route="$currentRoute" />
|
||||
|
||||
<div class="min-w-0">
|
||||
@if ($currentRoute === 'project.database.configuration')
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
'navigate' => false,
|
||||
],
|
||||
[
|
||||
'label' => 'Console',
|
||||
'label' => 'Terminal',
|
||||
'route' => 'project.database.command',
|
||||
'active' => request()->routeIs('project.database.command'),
|
||||
'navigate' => false,
|
||||
@@ -77,7 +77,7 @@
|
||||
@endteleport
|
||||
|
||||
<div x-data>
|
||||
<div class="mb-3 w-full lg:hidden">
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
{{ $database->name }}
|
||||
@@ -86,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:hidden">
|
||||
<div class="w-full xl:hidden">
|
||||
@if ($database->destination->server->isFunctional())
|
||||
<div id="database-mobile-actions" class="relative mb-3"
|
||||
x-data="{ open: false }" @click.outside="open = false"
|
||||
@@ -149,56 +149,37 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
<x-resource-heading-tabs class="min-w-0 flex-1">
|
||||
@foreach ($databasePageItems as $menuItem)
|
||||
<a @class([
|
||||
'app-tab shrink-0',
|
||||
'app-tab-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['active']) aria-current="page" @endif
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</x-resource-heading-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-full items-center md:flex lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
|
||||
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
|
||||
<div class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto">
|
||||
<div
|
||||
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
<x-resource-heading-tabs class="min-w-0">
|
||||
@foreach ($databasePageItems as $menuItem)
|
||||
<a wire:key="database-primary-nav-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'app-tab shrink-0',
|
||||
'app-tab-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['active']) aria-current="page" @endif
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</x-resource-heading-tabs>
|
||||
|
||||
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
|
||||
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
|
||||
@if ($database->destination->server->isFunctional())
|
||||
@if (! $databaseStatus->startsWith('exited'))
|
||||
<x-forms.button canGate="manage" :canResource="$database"
|
||||
@click="document.getElementById('database-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-4 text-orange-500 dark:text-warning" />
|
||||
Restart
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="manage" :canResource="$database" isError
|
||||
@click="document.getElementById('database-stop-trigger')?.click()">
|
||||
<x-reicon name="stop" class="size-4 text-error" />
|
||||
Stop
|
||||
</x-forms.button>
|
||||
<div id="database-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="menu">
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('manage', $database))
|
||||
@click="open = false; document.getElementById('database-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
|
||||
Restart
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('manage', $database))
|
||||
@click="open = false; document.getElementById('database-stop-trigger')?.click()">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.button canGate="manage" :canResource="$database"
|
||||
@click="$wire.dispatch('startEvent')">
|
||||
@@ -213,7 +194,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
@if ($database->destination->server->isFunctional())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div x-data="{
|
||||
<div class="flex min-w-0 flex-col gap-3" x-data="{
|
||||
search: '',
|
||||
backups: @js($database->scheduledBackups->map(fn ($backup) => [
|
||||
'name' => strtolower($database->name),
|
||||
@@ -41,12 +41,12 @@
|
||||
</form>
|
||||
@else
|
||||
@if ($database->scheduledBackups->isNotEmpty())
|
||||
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.06]">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="relative max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input type="search" x-model="search"
|
||||
class="input h-8! pl-8! text-[13px]!" placeholder="Search backup schedules…" />
|
||||
<input type="search" x-model="search" aria-label="Search backups"
|
||||
class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search backups" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -56,7 +56,7 @@
|
||||
description="Create a schedule to start protecting this database."
|
||||
icon-name="storages" />
|
||||
@else
|
||||
<div x-cloak x-show="search === '' || hasMatches()" class="data-table">
|
||||
<div x-cloak x-show="search === '' || hasMatches()" class="data-table overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="data-table-header scheduled-backups-table-grid">
|
||||
<span>Schedule</span>
|
||||
<span>Latest run</span>
|
||||
@@ -83,10 +83,9 @@
|
||||
|| @js(strtolower($backup->s3?->name ?? '')).includes(search.toLowerCase())"
|
||||
class="data-table-row scheduled-backups-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]">
|
||||
<div class="min-w-0">
|
||||
<a class="block truncate text-[12px] font-semibold text-black dark:text-fg"
|
||||
{{ wireNavigate() }} href="{{ $backupRoute }}">
|
||||
<span class="block truncate text-[12px] font-semibold text-black dark:text-fg">
|
||||
{{ $backup->frequency }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-status-badge :status="$statusLabel" :type="$statusType" />
|
||||
@@ -98,7 +97,7 @@
|
||||
{{ $backup->save_s3 ? ($backup->s3?->name ?? 'Unavailable') : 'Local only' }}
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
|
||||
{{ $backup->executions()->count() }}
|
||||
{{ $backup->executions_count ?? $backup->executions()->count() }}
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>
|
||||
|
||||
@@ -107,8 +107,10 @@
|
||||
class="group relative flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<a :href="project.href" {{ wireNavigate() }} class="absolute inset-0 rounded-xl"
|
||||
:aria-label="`Open ${project.name}`"></a>
|
||||
<x-reicon name="arrow-right"
|
||||
class="pointer-events-none absolute top-1/2 right-3 size-3.5 -translate-y-1/2 text-neutral-300 opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100 dark:text-fg-faint" />
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-start gap-3 pr-6">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<x-reicon name="projects" class="size-4" />
|
||||
|
||||
@@ -17,12 +17,15 @@
|
||||
['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'],
|
||||
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables'],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
|
||||
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
|
||||
['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
|
||||
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
|
||||
['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'],
|
||||
['label' => 'Tags', 'route' => 'project.service.tags', 'icon' => 'tags'],
|
||||
['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'shield-alert'],
|
||||
])->map(fn (array $item): array => [
|
||||
])->filter(fn (array $item): bool => $item['visible'] ?? true)->map(fn (array $item): array => [
|
||||
...$item,
|
||||
'active' => $currentRoute === $item['route']
|
||||
|| ($item['route'] === 'project.service.scheduled-tasks.show'
|
||||
@@ -30,9 +33,10 @@
|
||||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
|
||||
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'],
|
||||
'Automation' => ['Scheduled Tasks', 'Webhooks'],
|
||||
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
|
||||
'Logs' => ['Runtime'],
|
||||
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'],
|
||||
];
|
||||
|
||||
$groupedItems = collect($menuGroups)
|
||||
@@ -63,7 +67,7 @@
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
])
|
||||
{{ wireNavigate() }}
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
'navigate' => false,
|
||||
],
|
||||
[
|
||||
'label' => 'Console',
|
||||
'label' => 'Terminal',
|
||||
'route' => 'project.service.command',
|
||||
'active' => request()->routeIs('project.service.command'),
|
||||
'navigate' => false,
|
||||
@@ -74,7 +74,7 @@
|
||||
@endteleport
|
||||
|
||||
<div x-data>
|
||||
<div class="mb-3 w-full lg:hidden">
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
{{ $service->name }}
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:hidden">
|
||||
<div class="w-full xl:hidden">
|
||||
@if ($service->isDeployable)
|
||||
<div id="service-mobile-actions" class="relative mb-3"
|
||||
x-data="{ open: false }" @click.outside="open = false"
|
||||
@@ -178,66 +178,44 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
{{-- Tabs may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
|
||||
<x-resource-heading-tabs class="min-w-0 flex-1">
|
||||
@foreach ($servicePageItems as $menuItem)
|
||||
<a @class([
|
||||
'app-tab shrink-0',
|
||||
'app-tab-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['active']) aria-current="page" @endif
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</x-resource-heading-tabs>
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-services.links :service="$service" />
|
||||
</div>
|
||||
<div class="resource-heading-menus w-full">
|
||||
<x-services.links :service="$service" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-full items-center md:flex lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
|
||||
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
|
||||
<div class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto">
|
||||
<div
|
||||
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
<div class="flex min-w-0 items-center gap-0.5">
|
||||
<x-resource-heading-tabs class="min-w-0">
|
||||
@foreach ($servicePageItems as $menuItem)
|
||||
<a wire:key="service-primary-nav-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'app-tab shrink-0',
|
||||
'app-tab-active' => $menuItem['active'],
|
||||
])
|
||||
@if ($menuItem['active']) aria-current="page" @endif
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $parameters) }}">
|
||||
{{ $menuItem['label'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</x-resource-heading-tabs>
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-services.links :service="$service" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
|
||||
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
|
||||
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
|
||||
@if ($service->isDeployable)
|
||||
<x-services.advanced :service="$service" />
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-services.links :service="$service" />
|
||||
</div>
|
||||
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
|
||||
<x-forms.button canGate="deploy" :canResource="$service"
|
||||
@click="document.getElementById('service-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-4 text-orange-500 dark:text-warning" />
|
||||
Restart
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="stop" :canResource="$service" isError
|
||||
@click="document.getElementById('service-stop-trigger')?.click()">
|
||||
<x-reicon name="stop" class="size-4 text-error" />
|
||||
Stop
|
||||
</x-forms.button>
|
||||
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="menu">
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="open = false; document.getElementById('service-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
|
||||
Restart
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.button canGate="deploy" :canResource="$service"
|
||||
@click="$wire.dispatch('startEvent')">
|
||||
@@ -252,7 +230,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
@if ($service->isDeployable)
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="application-settings-section-body is-flush w-full">
|
||||
<div @class([
|
||||
'application-settings-section-body w-full',
|
||||
'is-flush' => $backups->isNotEmpty(),
|
||||
])>
|
||||
<div x-cloak x-show="backups.length > 0 && filteredBackups.length === 0">
|
||||
<x-empty size="sm" title="No backups found"
|
||||
description="No scheduled backups match your search." />
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
@endif
|
||||
<button type="button"
|
||||
class="ml-1 inline-flex items-center gap-1 font-semibold text-coollabs transition-colors hover:text-coollabs-100 dark:text-warning dark:hover:text-warning/80"
|
||||
x-on:click="$wire.refreshConfigurationChanges().then(() => configurationDiffModalOpen = true)"
|
||||
wire:loading.attr="disabled" wire:target="refreshConfigurationChanges">
|
||||
x-on:click="configurationDiffModalOpen = true">
|
||||
View changes
|
||||
<x-reicon name="arrow-right" class="size-3" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($resource, 'name')->limit(10) }} > Console | Coolify
|
||||
{{ data_get_str($resource, 'name')->limit(10) }} > Terminal | Coolify
|
||||
</x-slot>
|
||||
|
||||
@if ($type === 'application')
|
||||
@@ -11,7 +11,7 @@
|
||||
<livewire:project.database.heading :database="$resource" />
|
||||
@elseif ($type === 'service')
|
||||
<livewire:project.shared.configuration-checker :resource="$resource" />
|
||||
<livewire:project.service.heading :service="$resource" :parameters="$parameters" title="Console" />
|
||||
<livewire:project.service.heading :service="$resource" :parameters="$parameters" title="Terminal" />
|
||||
@else
|
||||
<livewire:server.navbar :server="$servers->first()" />
|
||||
@endif
|
||||
@@ -20,6 +20,7 @@
|
||||
$consoleUnavailable = ($type === 'server' && (! $servers->first()->isTerminalEnabled() || ! $servers->first()->isFunctional()))
|
||||
|| ($type !== 'server' && $containers->isEmpty());
|
||||
$consoleThemes = [
|
||||
['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, #121214 50% 100%)', 'accent' => '#8C8E9C'],
|
||||
['key' => 'shadows-midnight', 'name' => 'Midnight', 'background' => 'linear-gradient(135deg, #2a3b4c, rgba(42, 59, 76, 0.4))', 'accent' => '#6d7a7c'],
|
||||
['key' => 'shadows-golden-hour', 'name' => 'Golden Hour', 'background' => 'linear-gradient(135deg, #d58a42, rgba(213, 138, 66, 0.4))', 'accent' => '#bf8c3c'],
|
||||
['key' => 'shadows-cosmic-purple', 'name' => 'Cosmic Purple', 'background' => 'linear-gradient(135deg, #5d3e66, rgba(93, 62, 102, 0.4))', 'accent' => '#A76DBE'],
|
||||
@@ -40,14 +41,27 @@
|
||||
])->values();
|
||||
@endphp
|
||||
|
||||
@if (in_array($type, ['application', 'database', 'service'], true))
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
@if ($type === 'application')
|
||||
<x-application.configuration-sidebar :application="$resource" current-route="project.application.command" />
|
||||
@elseif ($type === 'database')
|
||||
<x-database.configuration-sidebar :database="$resource" current-route="project.database.command" />
|
||||
@else
|
||||
<x-service.configuration-sidebar :service="$resource" current-route="project.service.command" />
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
@endif
|
||||
|
||||
@if ($consoleUnavailable)
|
||||
<section class="mt-8 w-full max-w-[1180px] xl:mt-0">
|
||||
@if ($type === 'server')
|
||||
<x-empty size="lg" title="Console unavailable"
|
||||
<x-empty size="lg" title="Terminal unavailable"
|
||||
description="This server is not functional or terminal access is disabled."
|
||||
icon-name="browser-terminal" />
|
||||
@else
|
||||
<x-empty size="lg" title="Console unavailable"
|
||||
<x-empty size="lg" title="Terminal unavailable"
|
||||
description="No containers are running, or terminal access is disabled on the destination server."
|
||||
icon-name="browser-terminal" />
|
||||
@endif
|
||||
@@ -56,14 +70,14 @@
|
||||
<section class="mt-8 mb-0! h-[calc(100dvh-8rem)] min-h-[32rem] w-full max-w-[1180px] xl:mt-0"
|
||||
x-data="{
|
||||
themeKeys: @js($consoleThemeKeys),
|
||||
consoleTheme: 'shadows-cosmic-purple',
|
||||
consoleTheme: 'system',
|
||||
themeOpen: false,
|
||||
containerOpen: false,
|
||||
selectedContainer: @entangle('selected_container').live,
|
||||
containerOptions: @js($containerOptions),
|
||||
init() {
|
||||
const savedTheme = localStorage.getItem('coolify-console-theme');
|
||||
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'shadows-cosmic-purple';
|
||||
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
|
||||
localStorage.setItem('coolify-console-theme', this.consoleTheme);
|
||||
},
|
||||
setTheme(theme) {
|
||||
@@ -170,10 +184,10 @@
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
|
||||
class="absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-white/[0.1] bg-[#111113] p-1 shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
|
||||
class="console-theme-selector absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
|
||||
@foreach ($consoleThemes as $theme)
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
|
||||
@click="setTheme('{{ $theme['key'] }}')">
|
||||
<span class="h-3 w-5 rounded-full border border-white/10"
|
||||
style="background: {{ $theme['background'] }}"></span>
|
||||
@@ -196,4 +210,9 @@
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@if (in_array($type, ['application', 'database', 'service'], true))
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
<x-application.settings-section id="healthcheck-configuration-section" title="Healthcheck"
|
||||
helper="Define how Coolify determines whether this application is ready to receive traffic.">
|
||||
<x-slot:actions>
|
||||
<x-status-badge :status="$healthCheckEnabled ? 'Enabled' : 'Disabled'"
|
||||
:type="$healthCheckEnabled ? 'success' : 'neutral'" />
|
||||
@if (!$healthCheckEnabled)
|
||||
<x-modal-confirmation :disabled="!auth()->user()->can('update', $resource)"
|
||||
:authDisabled="!auth()->user()->can('update', $resource)"
|
||||
|
||||
@@ -24,15 +24,28 @@
|
||||
);
|
||||
@endphp
|
||||
|
||||
@if (in_array($type, ['application', 'database', 'service'], true))
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
@if ($type === 'application')
|
||||
<x-application.configuration-sidebar :application="$resource" current-route="project.application.logs" />
|
||||
@elseif ($type === 'database')
|
||||
<x-database.configuration-sidebar :database="$resource" current-route="project.database.logs" />
|
||||
@else
|
||||
<x-service.configuration-sidebar :service="$resource" current-route="project.service.logs" />
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
@endif
|
||||
|
||||
<div wire:loading.flex wire:target="loadAllContainers"
|
||||
class="mt-4 hidden min-h-32 w-full items-center justify-center lg:mt-3">
|
||||
class="loading-state-card mt-4 hidden min-h-40 w-full items-center justify-center lg:mt-3">
|
||||
<x-loading text="Loading containers" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 w-full lg:mt-3" x-init="if (! $wire.containersLoaded) { $wire.loadAllContainers() }"
|
||||
wire:loading.remove wire:target="loadAllContainers">
|
||||
@if (! $containersLoaded)
|
||||
<div class="flex min-h-32 items-center justify-center">
|
||||
<div class="loading-state-card flex min-h-40 w-full items-center justify-center">
|
||||
<x-loading text="Loading containers" />
|
||||
</div>
|
||||
@elseif ($logsUnavailable)
|
||||
@@ -75,4 +88,9 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if (in_array($type, ['application', 'database', 'service'], true))
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<div x-data="{
|
||||
selectedCloneServer: null,
|
||||
selectedCloneDestination: null,
|
||||
selectedCloneProject: null,
|
||||
selectedCloneEnvironment: null,
|
||||
selectedMoveProject: null,
|
||||
selectedMoveEnvironment: null,
|
||||
currentProjectId: {{ $resource->environment->project->id }},
|
||||
currentEnvironmentId: {{ $resource->environment->id }},
|
||||
currentServerId: @js($resource->destination->server->id),
|
||||
currentDestinationUuid: @js($resource->destination->uuid),
|
||||
servers: @js(
|
||||
$servers->map(
|
||||
fn ($server) => [
|
||||
@@ -38,10 +42,15 @@
|
||||
)->values(),
|
||||
),
|
||||
get availableDestinations() {
|
||||
if (!this.selectedCloneServer) return [];
|
||||
if (this.selectedCloneServer === null || this.selectedCloneServer === '') return [];
|
||||
const server = this.servers.find(server => server.id == this.selectedCloneServer);
|
||||
return server ? server.destinations : [];
|
||||
},
|
||||
get availableCloneEnvironments() {
|
||||
if (this.selectedCloneProject === null || this.selectedCloneProject === '') return [];
|
||||
const project = this.projects.find(project => project.id == this.selectedCloneProject);
|
||||
return project ? project.environments : [];
|
||||
},
|
||||
get availableEnvironments() {
|
||||
if (!this.selectedMoveProject) return [];
|
||||
const project = this.projects.find(project => project.id == this.selectedMoveProject);
|
||||
@@ -60,7 +69,7 @@
|
||||
return [
|
||||
...this.servers.map(server => ({
|
||||
value: server.id,
|
||||
label: `${server.name} (${server.ip})`,
|
||||
label: `${server.name} (${server.ip})${server.id == this.currentServerId ? ' (current)' : ''}`,
|
||||
})),
|
||||
...@js(
|
||||
$buildServers->map(
|
||||
@@ -76,7 +85,19 @@
|
||||
get cloneDestinationOptions() {
|
||||
return this.availableDestinations.map(destination => ({
|
||||
value: destination.uuid,
|
||||
label: destination.name,
|
||||
label: destination.name + (destination.uuid == this.currentDestinationUuid ? ' (current)' : ''),
|
||||
}));
|
||||
},
|
||||
get cloneProjectOptions() {
|
||||
return this.projects.map(project => ({
|
||||
value: project.id,
|
||||
label: project.name + (project.id == this.currentProjectId ? ' (current)' : ''),
|
||||
}));
|
||||
},
|
||||
get cloneEnvironmentOptions() {
|
||||
return this.availableCloneEnvironments.map(environment => ({
|
||||
value: environment.id,
|
||||
label: environment.name + (environment.id == this.currentEnvironmentId ? ' (current)' : ''),
|
||||
}));
|
||||
},
|
||||
get moveProjectOptions() {
|
||||
@@ -92,12 +113,17 @@
|
||||
}));
|
||||
}
|
||||
}" x-init="
|
||||
selectedCloneServer = null;
|
||||
selectedCloneDestination = null;
|
||||
selectedCloneProject = null;
|
||||
selectedCloneEnvironment = null;
|
||||
$watch('selectedCloneServer', () => selectedCloneDestination = null);
|
||||
$watch('selectedCloneProject', () => selectedCloneEnvironment = null);
|
||||
$watch('selectedMoveProject', () => selectedMoveEnvironment = null);
|
||||
" class="flex flex-col gap-6">
|
||||
@can('update', $resource)
|
||||
<x-application.settings-section id="clone-resource-section" title="Clone resource"
|
||||
helper="Duplicate this resource configuration onto another server and network destination.">
|
||||
<x-application.settings-section id="clone-destination-section" title="Clone to another destination"
|
||||
helper="Create the clone in the current environment on another server or network.">
|
||||
<x-callout type="info" title="Configuration only">
|
||||
Cloning copies settings, environment variables, and resource configuration. Stored files,
|
||||
database records, and other persistent data are not copied.
|
||||
@@ -110,7 +136,8 @@
|
||||
|
||||
<x-forms.listbox id="clone-resource-destination" label="Network destination" :wire="false"
|
||||
x-model="selectedCloneDestination" x-effect="options = cloneDestinationOptions"
|
||||
x-bind:disabled="!selectedCloneServer" placeholder="Choose a destination…"
|
||||
x-bind:disabled="selectedCloneServer === null || selectedCloneServer === ''"
|
||||
placeholder="Choose a destination…"
|
||||
emptyText="No network destinations are available on this server." />
|
||||
</div>
|
||||
|
||||
@@ -125,6 +152,32 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="clone-environment-section" title="Clone to another environment"
|
||||
helper="Create the clone in another project environment while keeping the current server and network.">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<x-forms.listbox id="clone-resource-project" label="Project" :wire="false"
|
||||
x-model="selectedCloneProject" x-effect="options = cloneProjectOptions"
|
||||
placeholder="Choose a project…" />
|
||||
|
||||
<x-forms.listbox id="clone-resource-environment" label="Environment" :wire="false"
|
||||
x-model="selectedCloneEnvironment" x-effect="options = cloneEnvironmentOptions"
|
||||
x-bind:disabled="!selectedCloneProject || availableCloneEnvironments.length === 0"
|
||||
placeholder="Choose an environment…" />
|
||||
</div>
|
||||
|
||||
<div x-show="selectedCloneEnvironment" x-cloak
|
||||
class="mt-4 flex flex-col gap-3 border-t border-neutral-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-white/[0.07]">
|
||||
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
Uses {{ data_get($resource, 'destination.server.name') }} ·
|
||||
{{ data_get($resource, 'destination.network') }}.
|
||||
</p>
|
||||
<x-forms.button
|
||||
@click="$wire.cloneTo(@js($resource->destination->uuid), selectedCloneEnvironment)">
|
||||
Clone resource
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="move-resource-section" title="Move resource"
|
||||
helper="Transfer this resource to another project environment without changing the running deployment.">
|
||||
@if ($projects->count() > 0)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
@endcan
|
||||
@endif
|
||||
@can('update', $resource)
|
||||
<x-forms.button type="button" wire:click="toggleEnabled">
|
||||
<x-forms.button type="button" wire:click="toggleEnabled" :isHighlighted="!$isEnabled">
|
||||
{{ $isEnabled ? 'Disable' : 'Enable' }}
|
||||
</x-forms.button>
|
||||
<x-modal-confirmation title="Delete scheduled task?" isErrorButton buttonTitle="Delete"
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($showActionsColumn)
|
||||
@if ($showBackupAction)
|
||||
<div
|
||||
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
|
||||
@if ($canUpdate)
|
||||
@@ -170,7 +170,7 @@
|
||||
Update
|
||||
</x-forms.button>
|
||||
|
||||
@if ($showActionsColumn)
|
||||
@if ($showBackupAction)
|
||||
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<x-forms.button type="button" class="!px-2.5 !text-xs">Backup</x-forms.button>
|
||||
@@ -185,6 +185,14 @@
|
||||
wire:key="configure-service-volume-backup-{{ $id }}" />
|
||||
@endif
|
||||
</x-modal-input>
|
||||
@elseif (method_exists($resource, 'isBackupSolutionAvailable') && $resource->isBackupSolutionAvailable())
|
||||
<x-modal-input title="New Scheduled Backup" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<x-forms.button type="button" class="!px-2.5 !text-xs">Backup</x-forms.button>
|
||||
</x-slot:content>
|
||||
<livewire:project.database.create-scheduled-backup :database="$resource"
|
||||
wire:key="configure-database-backup-{{ $id }}" />
|
||||
</x-modal-input>
|
||||
@endif
|
||||
|
||||
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@if ($isApplicationConsole)
|
||||
<div class="flex h-full min-h-[32rem] items-center justify-center">
|
||||
<x-empty size="lg" title="Shell unavailable"
|
||||
description="This container does not include Bash or sh. Install a supported shell to use the console."
|
||||
description="This container does not include Bash or sh. Install a supported shell to use the terminal."
|
||||
icon-name="browser-terminal" />
|
||||
</div>
|
||||
@else
|
||||
|
||||
@@ -99,8 +99,10 @@
|
||||
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<template x-for="server in filteredServers" :key="server.uuid">
|
||||
<a :href="server.href" {{ wireNavigate() }}
|
||||
class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<div class="flex items-start gap-3">
|
||||
class="group relative flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<x-reicon name="arrow-right"
|
||||
class="pointer-events-none absolute top-1/2 right-3 size-3.5 -translate-y-1/2 text-neutral-300 opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100 dark:text-fg-faint" />
|
||||
<div class="flex items-start gap-3 pr-6">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||||
<x-reicon name="servers" class="size-4" />
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
]" confirmationText="{{ $server->name }}"
|
||||
shortConfirmationLabel="Server Name"
|
||||
step3ButtonText="{{ $isTerminalEnabled ? 'Disable Terminal' : 'Enable Terminal' }}"
|
||||
isHighlightedButton />
|
||||
:isHighlightedButton="!$isTerminalEnabled" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<a href="#{{ $provider }}-oauth-section" class="menu-item min-h-8! py-1! text-[12px]!"
|
||||
:class="{ 'menu-item-active': activeProvider === '{{ $provider }}' }"
|
||||
@click.prevent="activeProvider = '{{ $provider }}'; history.replaceState(null, '', '#{{ $provider }}-oauth-section'); window.scrollToSettingsSection?.('{{ $provider }}-oauth-section')">
|
||||
<x-reicon name="keys" class="menu-item-icon" />
|
||||
<span class="menu-item-icon bg-current"
|
||||
style="mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat;"></span>
|
||||
<span class="menu-item-label">{{ $providerLabel }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
@@ -37,7 +38,7 @@
|
||||
title="{{ $providerLabel }}">
|
||||
<x-slot:actions>
|
||||
<div x-data="{ enabled: @js((bool) $oauth_setting['enabled']), provider: @js($provider) }">
|
||||
<x-forms.button type="button"
|
||||
<x-forms.button type="button" :isHighlighted="!$oauth_setting['enabled']"
|
||||
x-on:click="
|
||||
if (!enabled) {
|
||||
const invalidField = [...$el.closest('section').querySelectorAll('[required]')]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@php
|
||||
$consoleThemes = [
|
||||
['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, #121214 50% 100%)', 'accent' => '#8C8E9C'],
|
||||
['key' => 'shadows-midnight', 'name' => 'Midnight', 'background' => 'linear-gradient(135deg, #2a3b4c, rgba(42, 59, 76, 0.4))', 'accent' => '#6d7a7c'],
|
||||
['key' => 'shadows-golden-hour', 'name' => 'Golden Hour', 'background' => 'linear-gradient(135deg, #d58a42, rgba(213, 138, 66, 0.4))', 'accent' => '#bf8c3c'],
|
||||
['key' => 'shadows-cosmic-purple', 'name' => 'Cosmic Purple', 'background' => 'linear-gradient(135deg, #5d3e66, rgba(93, 62, 102, 0.4))', 'accent' => '#A76DBE'],
|
||||
@@ -63,7 +64,7 @@
|
||||
targetOpen: false,
|
||||
targetSearch: '',
|
||||
themeKeys: @js($consoleThemeKeys),
|
||||
consoleTheme: 'shadows-cosmic-purple',
|
||||
consoleTheme: 'system',
|
||||
themeOpen: false,
|
||||
get filteredTargets() {
|
||||
const query = this.targetSearch.trim().toLowerCase();
|
||||
@@ -73,7 +74,7 @@
|
||||
},
|
||||
init() {
|
||||
const savedTheme = localStorage.getItem('coolify-console-theme');
|
||||
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'shadows-cosmic-purple';
|
||||
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
|
||||
localStorage.setItem('coolify-console-theme', this.consoleTheme);
|
||||
},
|
||||
setTheme(theme) {
|
||||
@@ -191,10 +192,10 @@
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
|
||||
class="absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-white/[0.1] bg-[#111113] p-1 shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
|
||||
class="console-theme-selector absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
|
||||
@foreach ($consoleThemes as $theme)
|
||||
<button type="button"
|
||||
class="flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
|
||||
class="flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
|
||||
x-on:click="setTheme('{{ $theme['key'] }}')">
|
||||
<span class="h-3 w-5 rounded-full border border-white/10"
|
||||
style="background: {{ $theme['background'] }}"></span>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
test('compose actions are grouped with the application details header', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
$applicationDetails = str($view)
|
||||
->after('class="application-details-card">')
|
||||
->before('</x-application.settings-section>')
|
||||
->toString();
|
||||
|
||||
expect($applicationDetails)
|
||||
->toContain('<x-slot:actions>')
|
||||
->toContain('x-on:click="$wire.dispatch(\'loadCompose\', false)"')
|
||||
->toContain('Reload compose');
|
||||
});
|
||||
|
||||
test('docker compose heading separates its title and action', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
|
||||
|
||||
expect($view)->toContain('<div class="flex items-center gap-4">');
|
||||
});
|
||||
@@ -129,16 +129,16 @@ it('does not mark settings tab active on deployment logs', function () {
|
||||
->assertSuccessful()
|
||||
->getContent();
|
||||
|
||||
expect($html)->toContain('Deployment Logs');
|
||||
expect($html)->toContain('Deployment');
|
||||
|
||||
expect(preg_match(
|
||||
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Settings\s*<\/a>/s',
|
||||
$html
|
||||
))->toBe(0);
|
||||
|
||||
// Deployment Logs should be the active primary tab instead.
|
||||
// Deployment now lives in the Logs section of the settings sidebar.
|
||||
expect(preg_match(
|
||||
'/<a[^>]*(?:aria-current="page"[^>]*app-tab-active|app-tab-active[^>]*aria-current="page")[^>]*>\s*Deployment Logs\s*<\/a>/s',
|
||||
'/<a[^>]*menu-item-active[^>]*>.*?Deployment.*?<\/a>/s',
|
||||
$html
|
||||
))->toBe(1);
|
||||
});
|
||||
|
||||
@@ -273,6 +273,37 @@ YAML;
|
||||
expect(getComposeServiceDomainString($domains, 'another-service'))->toBe('https://legacy.example.com');
|
||||
});
|
||||
|
||||
test('applicationParser reads redirect settings from the compose service domain', function () {
|
||||
$this->server->proxy->set('type', 'TRAEFIK');
|
||||
$this->server->save();
|
||||
ServerSetting::query()
|
||||
->where('server_id', $this->server->id)
|
||||
->update(['generate_exact_labels' => true]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
YAML,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => [
|
||||
'domain' => 'https://example.com,https://www.example.com',
|
||||
'redirect' => 'www',
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$parsedCompose = applicationParser($application);
|
||||
$labels = collect(data_get($parsedCompose, 'services.frontend.labels'));
|
||||
|
||||
expect($labels->contains(fn (string $label): bool => str_contains($label, 'redirectregex.replacement=${1}://www.${2}')))->toBeTrue();
|
||||
});
|
||||
|
||||
test('compose domain reconciliation preserves stored domains when parsing returns no services', function () {
|
||||
$storedDomains = json_encode([
|
||||
'frontend' => ['domain' => 'https://frontend.example.com'],
|
||||
|
||||
@@ -67,6 +67,26 @@ test('cloneTo allows destination belonging to own team', function () {
|
||||
expect(Application::count())->toBe(2);
|
||||
});
|
||||
|
||||
test('cloneTo can place the cloned resource in another environment', function () {
|
||||
$targetEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
|
||||
->call('cloneTo', $this->destinationA->uuid, $targetEnvironment->id)
|
||||
->assertRedirect();
|
||||
|
||||
$clone = Application::whereKeyNot($this->applicationA->id)->firstOrFail();
|
||||
|
||||
expect($clone->environment_id)->toBe($targetEnvironment->id);
|
||||
});
|
||||
|
||||
test('cloneTo rejects an environment belonging to another team', function () {
|
||||
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
|
||||
->call('cloneTo', $this->destinationA->uuid, $this->environmentB->id)
|
||||
->assertHasErrors('environment_id');
|
||||
|
||||
expect(Application::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('moveTo rejects environment belonging to another team', function () {
|
||||
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
|
||||
->call('moveTo', $this->environmentB->id);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
it('uses a database list editor instead of a comma-separated text field', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<div class="grid w-full gap-4">')
|
||||
->toContain("value: @entangle('databasesToBackup').live")
|
||||
->toContain('class="chip-input"')
|
||||
->toContain('class="chip font-mono"')
|
||||
->toContain('class="chip-remove"')
|
||||
->toContain('addDatabase()')
|
||||
->toContain('removeDatabase(index)')
|
||||
->toContain('Type a database and press Enter');
|
||||
|
||||
expect(substr_count($view, '<x-forms.input label="Databases to back up"'))->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
it('matches the storage backup overview layout', function () {
|
||||
$index = file_get_contents(resource_path('views/livewire/project/database/backup/index.blade.php'));
|
||||
$schedules = file_get_contents(resource_path('views/livewire/project/database/scheduled-backups.blade.php'));
|
||||
$component = file_get_contents(app_path('Livewire/Project/Database/Backup/Index.php'));
|
||||
|
||||
expect($index)
|
||||
->toContain('title="Database backups"')
|
||||
->toContain('Schedules')
|
||||
->toContain('Enabled')
|
||||
->toContain('Total executions')
|
||||
->toContain('application-settings-form flex min-w-0 flex-col gap-6')
|
||||
->and($schedules)
|
||||
->toContain('placeholder="Search backups"')
|
||||
->toContain('data-table overflow-hidden rounded-xl border')
|
||||
->toContain('<span class="block truncate text-[12px] font-semibold')
|
||||
->toContain('<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>')
|
||||
->and($component)->toContain("withCount('executions')");
|
||||
});
|
||||
|
||||
it('returns from database backup settings to the database', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/backup-sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain("'back' => 'project.database.configuration'")
|
||||
->toContain("'Back to database'")
|
||||
->toContain("except('backup_uuid')");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
it('keeps editable standalone database storage actions in the table action column', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Project/Shared/Storages/All.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain('$this->showActionsColumn = $this->canUpdate;')
|
||||
->toContain('$this->showBackupAction = $this->resource instanceof Application')
|
||||
->toContain('if (! $this->showBackupAction)')
|
||||
->and($view)
|
||||
->toContain('@if ($showActionsColumn)')
|
||||
->toContain('@if ($showBackupAction)')
|
||||
->toContain('title="New Scheduled Backup"')
|
||||
->toContain('<livewire:project.database.create-scheduled-backup :database="$resource"')
|
||||
->toContain('Backup');
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('uses the Coolify base background for all empty states', function () {
|
||||
$html = Blade::render('<x-empty title="Nothing found" />');
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($html)
|
||||
->toContain('empty-state')
|
||||
->not->toContain('bg-neutral-50')
|
||||
->not->toContain('dark:bg-white/[0.02]')
|
||||
->and($css)
|
||||
->toContain('.application-settings-section-body:has(> .empty-state:only-child)')
|
||||
->toContain('.application-settings-section-body.is-flush:has(> .empty-state:only-child)')
|
||||
->toContain('padding: 1rem;')
|
||||
->toContain('.empty-state')
|
||||
->toContain('background: var(--coollabs-base);')
|
||||
->toContain('.loading-state-card')
|
||||
->toContain('border: 1px dashed var(--coollabs-line);');
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
it('uses highlighted styling for enable toggle actions', function () {
|
||||
$views = [
|
||||
resource_path('views/components/notification/channel-actions.blade.php'),
|
||||
resource_path('views/livewire/settings-oauth.blade.php'),
|
||||
resource_path('views/livewire/project/shared/scheduled-task/show.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))->toContain('isHighlighted');
|
||||
}
|
||||
|
||||
$terminalAccess = file_get_contents(resource_path('views/livewire/server/security/terminal-access.blade.php'));
|
||||
|
||||
expect($terminalAccess)->toContain(':isHighlightedButton="!$isTerminalEnabled"');
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
it('uses the current listbox design for environment variable suggestions', function () {
|
||||
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="listbox-panel')
|
||||
->toContain('class="listbox-option justify-start! gap-2.5!"')
|
||||
->toContain("'bg-neutral-100 dark:bg-white/[0.08]': index === selectedIndex")
|
||||
->toContain('border-warning/25 bg-warning/10')
|
||||
->toContain('border-emerald-500/25 bg-emerald-500/10')
|
||||
->not->toContain('dark:bg-coolgray-100');
|
||||
});
|
||||
@@ -62,7 +62,21 @@ test('listbox forwards dynamic disabled state to its trigger', function () {
|
||||
expect($listbox)->toContain("\$attributes->whereStartsWith('x-bind:disabled')");
|
||||
|
||||
expect($operations)
|
||||
->toContain('x-bind:disabled="!selectedCloneServer"')
|
||||
->toContain('selectedCloneServer: null')
|
||||
->toContain('selectedCloneProject: null')
|
||||
->toContain('selectedCloneEnvironment: null')
|
||||
->toContain('currentServerId: @js($resource->destination->server->id)')
|
||||
->toContain('currentDestinationUuid: @js($resource->destination->uuid)')
|
||||
->toContain("server.id == this.currentServerId ? ' (current)' : ''")
|
||||
->toContain("destination.uuid == this.currentDestinationUuid ? ' (current)' : ''")
|
||||
->toContain('selectedCloneServer = null;')
|
||||
->toContain('placeholder="Choose a server…"')
|
||||
->toContain("this.selectedCloneServer === null || this.selectedCloneServer === ''")
|
||||
->toContain("x-bind:disabled=\"selectedCloneServer === null || selectedCloneServer === ''\"")
|
||||
->toContain('x-model="selectedCloneProject"')
|
||||
->toContain('x-model="selectedCloneEnvironment"')
|
||||
->toContain('$wire.cloneTo(selectedCloneDestination)')
|
||||
->toContain('$wire.cloneTo(@js($resource->destination->uuid), selectedCloneEnvironment)')
|
||||
->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"');
|
||||
});
|
||||
|
||||
|
||||
@@ -59,19 +59,22 @@ it('does not render the notification for preview deployment toggles', function (
|
||||
->assertSet('isConfigurationChanged', false);
|
||||
});
|
||||
|
||||
it('renders the changed configuration labels', function () {
|
||||
it('renders the changed configuration labels without a second backend request', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
// Banner summary is always available; full change rows load on demand.
|
||||
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('A rebuild is required.')
|
||||
->assertDontSee('Build command')
|
||||
->call('refreshConfigurationChanges')
|
||||
->assertSee('Build command');
|
||||
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/configuration-checker.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('x-on:click="configurationDiffModalOpen = true"')
|
||||
->not->toContain('$wire.refreshConfigurationChanges()');
|
||||
});
|
||||
|
||||
it('refreshes configuration changes when the event is received', function () {
|
||||
@@ -88,7 +91,6 @@ it('refreshes configuration changes when the event is received', function () {
|
||||
->dispatch('configurationChanged')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->call('refreshConfigurationChanges')
|
||||
->assertSee('Build command');
|
||||
});
|
||||
|
||||
@@ -113,7 +115,6 @@ it('shows an unapplied configuration warning after a directory mount is added',
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('The latest configuration has not been applied')
|
||||
->assertSee('Please redeploy to apply the new configuration.')
|
||||
->call('refreshConfigurationChanges')
|
||||
->assertSee('Directory mount');
|
||||
});
|
||||
|
||||
@@ -124,7 +125,6 @@ it('refreshes stale modal configuration diff before opening changes', function (
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->call('refreshConfigurationChanges')
|
||||
->assertSee('Build command')
|
||||
->assertDontSee('Start command');
|
||||
|
||||
@@ -134,13 +134,13 @@ it('refreshes stale modal configuration diff before opening changes', function (
|
||||
]);
|
||||
|
||||
$component
|
||||
->call('refreshConfigurationChanges')
|
||||
->dispatch('configurationChanged')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('Start command')
|
||||
->assertDontSee('Build command');
|
||||
});
|
||||
|
||||
it('keeps full configuration change rows out of the initial Livewire snapshot', function () {
|
||||
it('includes full configuration change rows in the initial Livewire snapshot', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
$application->update(['build_command' => 'pnpm build']);
|
||||
@@ -148,12 +148,7 @@ it('keeps full configuration change rows out of the initial Livewire snapshot',
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]);
|
||||
|
||||
expect($component->get('isConfigurationChanged'))->toBeTrue()
|
||||
->and($component->get('configurationDiff'))->toHaveKeys(['count', 'requires_build'])
|
||||
->and($component->get('configurationDiff'))->not->toHaveKey('changes');
|
||||
|
||||
$component->call('refreshConfigurationChanges');
|
||||
|
||||
expect($component->get('configurationDiff'))->toHaveKey('changes')
|
||||
->and($component->get('configurationDiff'))->toHaveKey('changes')
|
||||
->and(data_get($component->get('configurationDiff'), 'changes'))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
@@ -177,8 +172,7 @@ it('redacts unlocked environment values for team members in the change list', fu
|
||||
|
||||
$application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->call('refreshConfigurationChanges');
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]);
|
||||
|
||||
$envChange = collect(data_get($component->get('configurationDiff'), 'changes', []))
|
||||
->first(fn (array $change): bool => str_contains((string) data_get($change, 'key'), 'API_TOKEN')
|
||||
@@ -211,7 +205,6 @@ it('redacts newly added environment values for team members', function () {
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->call('refreshConfigurationChanges')
|
||||
->assertSee('API_TOKEN')
|
||||
->assertSee('Current')
|
||||
->assertSee('New');
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
it('uses the current neutral loading indicator styles', function () {
|
||||
$loading = file_get_contents(resource_path('views/components/loading.blade.php'));
|
||||
|
||||
expect($loading)
|
||||
->toContain('gap-2 text-[13px] text-neutral-500 dark:text-fg-dim')
|
||||
->toContain('size-4 shrink-0 animate-spin')
|
||||
->toContain('stroke="currentColor" stroke-width="2"')
|
||||
->not->toContain('text-coollabs')
|
||||
->not->toContain('dark:text-warning');
|
||||
});
|
||||
|
||||
it('shows runtime container loading inside a settings card', function () {
|
||||
$logs = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php'));
|
||||
|
||||
expect($logs)
|
||||
->toContain('application-settings-section-body flex min-h-40 w-full items-center justify-center')
|
||||
->toContain('<x-loading text="Loading containers" />');
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
it('shows the shared hover arrow on project and server cards', function () {
|
||||
$views = [
|
||||
resource_path('views/livewire/project/index.blade.php'),
|
||||
resource_path('views/livewire/server/index.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
$contents = file_get_contents($view);
|
||||
|
||||
expect($contents)
|
||||
->toContain('<x-reicon name="arrow-right"')
|
||||
->toContain('group-hover:translate-x-0.5 group-hover:opacity-100')
|
||||
->toContain('pointer-events-none absolute top-1/2 right-3');
|
||||
}
|
||||
});
|
||||
@@ -14,30 +14,205 @@ it('uses a single unified navbar for application, service, database, and server
|
||||
foreach ($files as $path) {
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
expect($contents)
|
||||
->toContain('resource-heading-navbar')
|
||||
->toContain('justify-between')
|
||||
->toContain('border-l border-neutral-200 pl-1');
|
||||
expect($contents)->toContain('resource-heading-navbar');
|
||||
}
|
||||
|
||||
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
expect($application)
|
||||
->toContain('justify-start')
|
||||
->toContain('xl:justify-end')
|
||||
->toContain('xl:w-auto')
|
||||
->toContain('xl:bg-transparent')
|
||||
->toContain('xl:fixed')
|
||||
->toContain('xl:top-14')
|
||||
->toContain('xl:hidden')
|
||||
->not->toContain('application-primary-tabs')
|
||||
->not->toContain("typeof collapsed !== 'undefined'")
|
||||
->not->toContain('Spacer: in-flow stand-in')
|
||||
->not->toContain('hidden lg:block lg:h-12')
|
||||
->not->toContain('border-l border-neutral-200 pl-1');
|
||||
});
|
||||
|
||||
it('keeps links with the menu group and actions on the right for applications and services', function () {
|
||||
it('keeps application links next to advanced actions on the right', function () {
|
||||
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
|
||||
|
||||
$linksBeforeActionsDivider = function (string $contents, string $linksComponent): bool {
|
||||
$linksPos = strpos($contents, $linksComponent);
|
||||
$dividerPos = strpos($contents, 'border-l border-neutral-200 pl-1');
|
||||
$desktop = str($application)->after('resource-heading-actions flex')->toString();
|
||||
$advancedPosition = strpos($desktop, '<x-applications.advanced');
|
||||
$linksPosition = strpos($desktop, '<x-applications.links');
|
||||
|
||||
return $linksPos !== false && $dividerPos !== false && $linksPos < $dividerPos;
|
||||
};
|
||||
|
||||
expect($linksBeforeActionsDivider($application, '<x-applications.links'))->toBeTrue();
|
||||
expect($linksBeforeActionsDivider($service, '<x-services.links'))->toBeTrue();
|
||||
expect($advancedPosition)->not->toBeFalse()
|
||||
->and($linksPosition)->not->toBeFalse()
|
||||
->and($linksPosition)->toBeGreaterThan($advancedPosition)
|
||||
->and($links)->toContain('listbox-panel top-full! right-0! left-auto!')
|
||||
->and($links)->toContain('listbox-option justify-start! gap-2.5!')
|
||||
->and($links)->not->toContain('md:left-0 md:right-auto');
|
||||
|
||||
// Desktop layer uses exactly one unified pill bar (mobile section may still use its own pill).
|
||||
expect(substr_count($application, 'resource-heading-navbar'))->toBe(1);
|
||||
expect(substr_count($service, 'resource-heading-navbar'))->toBe(1);
|
||||
});
|
||||
|
||||
it('groups application lifecycle controls in an actions dropdown', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
|
||||
|
||||
expect($desktop)
|
||||
->toContain('application-desktop-actions')
|
||||
->toContain('Actions')
|
||||
->toContain('listbox-panel top-full! right-0! left-auto!')
|
||||
->toContain('Redeploy')
|
||||
->toContain('Restart')
|
||||
->toContain('Stop')
|
||||
->toContain('Deploy');
|
||||
});
|
||||
|
||||
it('shows deploy directly when it is the only available lifecycle action', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
|
||||
|
||||
expect($desktop)
|
||||
->toContain("@if (str(\$application->status)->startsWith('exited'))")
|
||||
->toContain('id="application-desktop-deploy"')
|
||||
->toContain('@else')
|
||||
->toContain('id="application-desktop-actions"');
|
||||
});
|
||||
|
||||
it('moves application backups from the top tabs into the settings sidebar', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
|
||||
$backup = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php'));
|
||||
|
||||
expect($heading)->not->toContain("'label' => 'Backups'")
|
||||
->and($configuration)->toContain('<x-application.configuration-sidebar')
|
||||
->and($backup)->toContain('<x-application.configuration-sidebar');
|
||||
});
|
||||
|
||||
it('moves application terminal and logs from the top tabs into the settings sidebar', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($heading)
|
||||
->not->toContain("'label' => 'Terminal'")
|
||||
->not->toContain("'label' => 'Deployment'")
|
||||
->not->toContain("'label' => 'Runtime'")
|
||||
->and($sidebar)
|
||||
->toContain("'label' => 'Terminal'")
|
||||
->toContain("'label' => 'Deployment'")
|
||||
->toContain("'label' => 'Runtime'")
|
||||
->toContain("'Logs' => ['Deployment', 'Runtime']")
|
||||
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'");
|
||||
});
|
||||
|
||||
it('groups application automation pages separately from build and deploy', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain("'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck']")
|
||||
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments']")
|
||||
->toContain("'Operations' => ['Terminal', 'Rollback', 'Resource Limits'");
|
||||
});
|
||||
|
||||
it('centers the rollback image loading state across the card', function () {
|
||||
$rollback = file_get_contents(resource_path('views/livewire/project/application/rollback.blade.php'));
|
||||
|
||||
expect($rollback)
|
||||
->toContain('class="w-full" wire:target="loadImages" wire:loading')
|
||||
->toContain('flex items-center justify-center');
|
||||
});
|
||||
|
||||
it('shows pull request loading feedback inside its settings card', function () {
|
||||
$previews = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
|
||||
|
||||
expect($previews)
|
||||
->toContain('class="w-full" wire:loading wire:target="load_prs"')
|
||||
->toContain('min-h-32 items-center justify-center')
|
||||
->toContain('<x-loading text="Loading pull requests…"');
|
||||
});
|
||||
|
||||
it('allows the shared empty state to control the storage backups background', function () {
|
||||
$backups = file_get_contents(resource_path('views/livewire/project/application/backup/index.blade.php'));
|
||||
|
||||
expect($backups)
|
||||
->toContain("'application-settings-section-body w-full'")
|
||||
->toContain("'is-flush' => \$backups->isNotEmpty()")
|
||||
->not->toContain('application-settings-section-body is-flush w-full bg-transparent!');
|
||||
});
|
||||
|
||||
it('keeps the application settings sidebar below the fixed header while scrolling', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($sidebar)->toContain('application-settings-navigation')
|
||||
->and($css)->toContain('.application-settings-workspace > .application-settings-navigation')
|
||||
->and($css)->toContain('top: 4rem;')
|
||||
->and($css)->toContain('max-height: calc(100dvh - 5rem);');
|
||||
});
|
||||
|
||||
it('builds application sidebar routes independently of the current request route', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->not->toContain('get_route_parameters()')
|
||||
->toContain("'project_uuid' => \$application->environment->project->uuid")
|
||||
->toContain("'environment_uuid' => \$application->environment->uuid")
|
||||
->toContain("'application_uuid' => \$application->uuid");
|
||||
});
|
||||
|
||||
it('keeps the deployment log sidebar fixed in the layout without a top gap', function () {
|
||||
$deployment = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($deployment)->toContain(':flush="true"')
|
||||
->and($css)->toContain('.application-settings-navigation.is-flush')
|
||||
->and($css)->toContain('position: static;')
|
||||
->and($css)->toContain('overflow: visible;');
|
||||
});
|
||||
|
||||
it('removes desktop top spacing from the deployment log viewer', function () {
|
||||
$deployment = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
|
||||
expect($deployment)->toContain("'mt-2 flex flex-1 min-h-0 flex-col overflow-hidden lg:mt-0'");
|
||||
});
|
||||
|
||||
it('uses a distinct runtime log icon in the sidebar', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)->toContain("'Runtime' => 'unordered-list'");
|
||||
});
|
||||
|
||||
it('shows deployment history above the selected deployment logs', function () {
|
||||
$indexClass = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
|
||||
$indexView = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
|
||||
$showView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
|
||||
expect($indexClass)
|
||||
->toContain('public bool $embedded = false;')
|
||||
->toContain('public ?string $selectedDeploymentUuid = null;')
|
||||
->and($indexView)
|
||||
->toContain("'data-table-row-active' => \$selectedDeploymentUuid")
|
||||
->and($showView)
|
||||
->toContain('<livewire:project.application.deployment.index :embedded="true"')
|
||||
->toContain(':selected-deployment-uuid="$deployment_uuid"');
|
||||
});
|
||||
|
||||
it('fits the combined deployment history and logs within the desktop viewport', function () {
|
||||
$indexClass = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
|
||||
$showView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
|
||||
expect($indexClass)->toContain('$this->defaultTake = 3;')
|
||||
->and($showView)
|
||||
->toContain('xl:h-[calc(100dvh-7.5rem)]')
|
||||
->toContain('xl:overflow-hidden')
|
||||
->toContain('xl:flex-1');
|
||||
});
|
||||
|
||||
it('uses only the healthcheck toggle action to communicate enabled state', function () {
|
||||
$healthcheck = file_get_contents(resource_path('views/livewire/project/shared/health-checks.blade.php'));
|
||||
|
||||
expect($healthcheck)
|
||||
->not->toContain('<x-status-badge')
|
||||
->toContain('buttonTitle="Enable"')
|
||||
->toContain('Disable');
|
||||
});
|
||||
|
||||
it('keeps Links dropdowns outside the scrollable tabs strip', function () {
|
||||
@@ -46,40 +221,16 @@ it('keeps Links dropdowns outside the scrollable tabs strip', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
$tabsComponent = file_get_contents(resource_path('views/components/resource-heading-tabs.blade.php'));
|
||||
|
||||
// Links must not sit inside the overflow-x-auto tabs scroller (causes scrollbar on open).
|
||||
// Application Links are standalone on mobile and next to actions on desktop.
|
||||
expect($application)
|
||||
->toContain('<x-resource-heading-tabs')
|
||||
->not->toContain('<x-resource-heading-tabs')
|
||||
->toContain('resource-heading-menus')
|
||||
->toContain('<x-applications.links');
|
||||
|
||||
// Mobile and desktop each have a tabs + menus pair; verify every pair.
|
||||
preg_match_all('/<x-resource-heading-tabs/', $application, $applicationTabsMatches, PREG_OFFSET_CAPTURE);
|
||||
preg_match_all('/resource-heading-menus/', $application, $applicationMenusMatches, PREG_OFFSET_CAPTURE);
|
||||
expect($applicationTabsMatches[0])->toHaveCount(2);
|
||||
expect($applicationMenusMatches[0])->toHaveCount(2);
|
||||
|
||||
foreach ($applicationTabsMatches[0] as $index => [$match, $tabsOffset]) {
|
||||
$menusOffset = $applicationMenusMatches[0][$index][1];
|
||||
$overflowInTabs = substr($application, $tabsOffset, $menusOffset - $tabsOffset);
|
||||
|
||||
expect($overflowInTabs)->not->toContain('<x-applications.links');
|
||||
expect(strpos($application, '<x-applications.links', $tabsOffset))
|
||||
->toBeGreaterThan($menusOffset);
|
||||
}
|
||||
|
||||
preg_match_all('/<x-resource-heading-tabs/', $service, $serviceTabsMatches, PREG_OFFSET_CAPTURE);
|
||||
preg_match_all('/resource-heading-menus/', $service, $serviceMenusMatches, PREG_OFFSET_CAPTURE);
|
||||
expect($serviceTabsMatches[0])->toHaveCount(2);
|
||||
expect($serviceMenusMatches[0])->toHaveCount(2);
|
||||
|
||||
foreach ($serviceTabsMatches[0] as $index => [$match, $tabsOffset]) {
|
||||
$menusOffset = $serviceMenusMatches[0][$index][1];
|
||||
$overflowInTabs = substr($service, $tabsOffset, $menusOffset - $tabsOffset);
|
||||
|
||||
expect($overflowInTabs)->not->toContain('<x-services.links');
|
||||
expect(strpos($service, '<x-services.links', $tabsOffset))
|
||||
->toBeGreaterThan($menusOffset);
|
||||
}
|
||||
expect($service)
|
||||
->not->toContain('<x-resource-heading-tabs')
|
||||
->toContain('resource-heading-menus')
|
||||
->toContain('<x-services.links');
|
||||
|
||||
expect($css)
|
||||
->toContain('.resource-heading-tabs::-webkit-scrollbar')
|
||||
@@ -101,22 +252,40 @@ it('keeps Links dropdowns outside the scrollable tabs strip', function () {
|
||||
->toContain('livewire:navigated');
|
||||
});
|
||||
|
||||
it('shows an icon in application and service Links controls', function () {
|
||||
$applicationLinks = file_get_contents(resource_path('views/components/applications/links.blade.php'));
|
||||
$serviceLinks = file_get_contents(resource_path('views/components/services/links.blade.php'));
|
||||
|
||||
expect($applicationLinks)->toContain("@props(['application', 'fullWidth' => false])")
|
||||
->toContain('<x-reicon name="external-link"')
|
||||
->and($serviceLinks)->toContain('<x-reicon name="external-link"')
|
||||
->and($applicationLinks)->not->toContain('<x-external-link')
|
||||
->and($serviceLinks)->not->toContain('<x-external-link');
|
||||
});
|
||||
|
||||
it('shows application and service Links on mobile headings', function () {
|
||||
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
|
||||
$mobileApplicationSection = str($application)->between('class="w-full md:hidden"', 'class="hidden w-full items-center md:flex')->toString();
|
||||
$mobileApplicationSection = str($application)->between('class="w-full xl:hidden"', 'class="hidden w-full items-center xl:fixed')->toString();
|
||||
$mobileServiceSection = str($service)->between('class="w-full md:hidden"', 'class="hidden w-full items-center md:flex')->toString();
|
||||
|
||||
expect($mobileApplicationSection)
|
||||
->toContain('<x-applications.links')
|
||||
->toContain('full-width')
|
||||
->toContain('resource-heading-menus')
|
||||
->toContain('<x-resource-heading-tabs');
|
||||
->not->toContain('<x-resource-heading-tabs')
|
||||
->not->toContain("'label' => 'Settings'");
|
||||
|
||||
$applicationLinks = file_get_contents(resource_path('views/components/applications/links.blade.php'));
|
||||
expect($applicationLinks)
|
||||
->toContain("'button w-full justify-between' => \$fullWidth")
|
||||
->toContain('<x-reicon name="chevron-down"');
|
||||
|
||||
expect($mobileServiceSection)
|
||||
->toContain('<x-services.links')
|
||||
->toContain('resource-heading-menus')
|
||||
->toContain('<x-resource-heading-tabs');
|
||||
->not->toContain('<x-resource-heading-tabs');
|
||||
|
||||
// One mobile + one desktop instance.
|
||||
expect(substr_count($application, '<x-applications.links'))->toBe(2);
|
||||
@@ -125,9 +294,6 @@ it('shows application and service Links on mobile headings', function () {
|
||||
|
||||
it('uses overflow scroll arrows on resource heading navbars', function () {
|
||||
$files = [
|
||||
resource_path('views/livewire/project/application/heading.blade.php'),
|
||||
resource_path('views/livewire/project/service/heading.blade.php'),
|
||||
resource_path('views/livewire/project/database/heading.blade.php'),
|
||||
resource_path('views/livewire/server/navbar.blade.php'),
|
||||
resource_path('views/components/project/navbar.blade.php'),
|
||||
];
|
||||
|
||||
@@ -436,5 +436,5 @@ test('resource operations explains why build servers cannot be clone targets', f
|
||||
Livewire::test(ResourceOperations::class, ['resource' => $source])
|
||||
->assertSet('buildServers', fn ($servers) => $servers->contains('id', $buildServer->id))
|
||||
->assertSee('Dedicated Builder')
|
||||
->assertSee('Build server — cannot host resources');
|
||||
->assertSee('build server');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
it('moves service and database page navigation into their sidebars', function () {
|
||||
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||
$serviceConfiguration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
|
||||
$databaseSidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($serviceHeading)->not->toContain('<x-resource-heading-tabs')
|
||||
->and($databaseHeading)->not->toContain('<x-resource-heading-tabs')
|
||||
->and($serviceConfiguration)
|
||||
->toContain("['label' => 'Backups'")
|
||||
->toContain("['label' => 'Runtime'")
|
||||
->toContain("['label' => 'Terminal'")
|
||||
->and($databaseSidebar)
|
||||
->toContain("['label' => 'Backups'")
|
||||
->toContain("['label' => 'Runtime'")
|
||||
->toContain("['label' => 'Terminal'");
|
||||
});
|
||||
|
||||
it('matches application action bar behavior for services and databases', function () {
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
$database = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||
|
||||
foreach ([$service, $database] as $heading) {
|
||||
expect($heading)
|
||||
->toContain('xl:fixed xl:top-14 xl:right-4')
|
||||
->toContain('xl:w-auto')
|
||||
->toContain('Actions')
|
||||
->toContain('listbox-panel top-full! right-0! left-auto!')
|
||||
->not->toContain('hidden lg:block lg:h-12');
|
||||
}
|
||||
|
||||
expect($service)->toContain('id="service-desktop-actions"')
|
||||
->and($database)->toContain('id="database-desktop-actions"');
|
||||
});
|
||||
|
||||
it('keeps database and service sidebar sections in the application sequence', function () {
|
||||
$database = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
|
||||
|
||||
expect($database)
|
||||
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Servers', 'Import Backup']")
|
||||
->toContain("'Automation' => ['Webhooks', 'Healthcheck']")
|
||||
->toContain("'Logs' => ['Runtime']")
|
||||
->toContain("'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone']");
|
||||
|
||||
expect($service)
|
||||
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups']")
|
||||
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks']")
|
||||
->toContain("'Logs' => ['Runtime']")
|
||||
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']");
|
||||
});
|
||||
|
||||
it('shows the database sidebar on backup pages', function () {
|
||||
$configuration = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php'));
|
||||
$backups = file_get_contents(resource_path('views/livewire/project/database/backup/index.blade.php'));
|
||||
$sidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($configuration)->toContain('<x-database.configuration-sidebar')
|
||||
->and($backups)
|
||||
->toContain('<x-database.configuration-sidebar')
|
||||
->toContain('current-route="project.database.backup.index"')
|
||||
->and($sidebar)
|
||||
->toContain("str(\$currentRoute)->startsWith('project.database.backup')");
|
||||
});
|
||||
|
||||
it('uses a distinct backup icon across resource sidebars', function () {
|
||||
$sidebars = [
|
||||
resource_path('views/components/application/configuration-sidebar.blade.php'),
|
||||
resource_path('views/components/database/configuration-sidebar.blade.php'),
|
||||
resource_path('views/livewire/project/service/configuration.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($sidebars as $sidebar) {
|
||||
$contents = file_get_contents($sidebar);
|
||||
|
||||
expect($contents)->toMatch("/['\"]Backups['\"].*['\"]database['\"]/s");
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the database sidebar on runtime logs', function () {
|
||||
$logs = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php'));
|
||||
|
||||
expect($logs)
|
||||
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
|
||||
->toContain('<x-database.configuration-sidebar :database="$resource" current-route="project.database.logs"')
|
||||
->toContain('loading-state-card');
|
||||
});
|
||||
|
||||
it('shows the database sidebar in the terminal', function () {
|
||||
$terminal = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
expect($terminal)
|
||||
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
|
||||
->toContain('<x-database.configuration-sidebar :database="$resource" current-route="project.database.command"');
|
||||
});
|
||||
|
||||
it('shows the service sidebar on runtime logs and terminal pages', function () {
|
||||
$logs = file_get_contents(resource_path('views/livewire/project/shared/logs.blade.php'));
|
||||
$terminal = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$sidebar = file_get_contents(resource_path('views/components/service/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($logs)
|
||||
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
|
||||
->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.logs"')
|
||||
->and($terminal)
|
||||
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
|
||||
->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.command"')
|
||||
->and($sidebar)
|
||||
->toContain("'Logs' => ['Runtime']")
|
||||
->toContain("'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone']");
|
||||
});
|
||||
@@ -32,3 +32,45 @@ it('requires the provider-specific fields used by oauth enablement', function ()
|
||||
'oauth_settings_map.authentik.base_url',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses monochrome brand logos for each oauth provider in the sidebar', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php'));
|
||||
|
||||
$providers = [
|
||||
'authentik',
|
||||
'azure',
|
||||
'bitbucket',
|
||||
'clerk',
|
||||
'discord',
|
||||
'github',
|
||||
'gitlab',
|
||||
'google',
|
||||
'infomaniak',
|
||||
'zitadel',
|
||||
];
|
||||
|
||||
expect($view)
|
||||
->toContain("asset('svgs/' . \$provider . '.svg')")
|
||||
->toContain('mask:')
|
||||
->toContain('bg-current')
|
||||
->not->toContain('<x-reicon name="keys" class="menu-item-icon" />');
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
$svg = public_path("svgs/{$provider}.svg");
|
||||
|
||||
expect($svg)->toBeFile();
|
||||
|
||||
$contents = file_get_contents($svg);
|
||||
|
||||
expect($contents)
|
||||
->toContain('<svg')
|
||||
->toContain('<path')
|
||||
->not->toMatch('/fill="#[0-9a-fA-F]{3,8}"/')
|
||||
->not->toContain('fill="#e24329"')
|
||||
->not->toContain('fill="#4285F4"');
|
||||
}
|
||||
|
||||
// Keep GitLab at the natural simple-icons 24x24 mark (no optical scale).
|
||||
expect(file_get_contents(public_path('svgs/gitlab.svg')))
|
||||
->not->toContain('scale(');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
it('renders section helper copy as a visible description instead of an info icon', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-application.settings-section title="Primary server" helper="The server and network used by this resource.">
|
||||
Content
|
||||
</x-application.settings-section>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('Primary server')
|
||||
->toContain('The server and network used by this resource.')
|
||||
->not->toContain('aria-label="More information"');
|
||||
});
|
||||
@@ -25,3 +25,11 @@ it('does not animate navbar padding when restoring collapsed state', function ()
|
||||
->not->toContain('items-start gap-3 motion-safe:transition-all')
|
||||
->not->toContain('overflow-hidden motion-safe:transition-all');
|
||||
});
|
||||
|
||||
it('draws a single border between the desktop sidebar and main content', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
|
||||
|
||||
expect($navbar)->toContain('border-r border-neutral-200')
|
||||
->and($layout)->not->toContain('lg:border-l border-neutral-200');
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ it('renders application and database statuses as shared status badges', function
|
||||
])->render();
|
||||
|
||||
expect($html)
|
||||
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
|
||||
->toContain('inline-flex h-6 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border')
|
||||
->toContain($expectedText)
|
||||
->not->toContain('badge-success')
|
||||
->not->toContain('badge-warning')
|
||||
@@ -21,7 +21,7 @@ it('renders application and database statuses as shared status badges', function
|
||||
'running healthy' => ['running:healthy', 'Running (healthy)'],
|
||||
'starting unknown' => ['starting:unknown', 'Starting (unknown)'],
|
||||
'degraded unhealthy' => ['degraded:unhealthy', 'Degraded (unhealthy)'],
|
||||
'exited unhealthy' => ['exited:unhealthy', 'Exited'],
|
||||
'exited unhealthy' => ['exited:unhealthy', 'Stopped'],
|
||||
]);
|
||||
|
||||
it('renders service container statuses as shared status badges', function () {
|
||||
@@ -31,7 +31,7 @@ it('renders service container statuses as shared status badges', function () {
|
||||
])->render();
|
||||
|
||||
expect($html)
|
||||
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
|
||||
->toContain('inline-flex h-6 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border')
|
||||
->toContain('Running (healthy)')
|
||||
->not->toContain('badge-success');
|
||||
});
|
||||
@@ -68,7 +68,7 @@ it('renders health warning helpers without increasing the badge row height', fun
|
||||
expect($html)
|
||||
->toContain($expectedText)
|
||||
->toContain('class="flex items-center gap-1 leading-none"')
|
||||
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
|
||||
->toContain('inline-flex h-6 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border')
|
||||
->not->toContain('<svg');
|
||||
|
||||
expect($runningStatus)
|
||||
@@ -108,7 +108,7 @@ it('renders restart counts as warning badges', function () {
|
||||
|
||||
expect($html)
|
||||
->toContain('9x restarts')
|
||||
->toContain('border-yellow-300 bg-yellow-50')
|
||||
->toContain('bg-warning')
|
||||
->not->toContain('(9x restarts)');
|
||||
|
||||
expect($statusIndex)
|
||||
|
||||
@@ -34,6 +34,7 @@ it('labels console themes without a Shadow\'s prefix', function () {
|
||||
|
||||
foreach ([$terminalView, $consoleView] as $view) {
|
||||
expect($view)
|
||||
->toContain("['key' => 'system', 'name' => 'System'")
|
||||
->toContain("'name' => 'Tropical Storm'")
|
||||
->toContain("'name' => 'Blur Black'")
|
||||
->toContain("'name' => 'Cosmic Purple'")
|
||||
@@ -41,7 +42,50 @@ it('labels console themes without a Shadow\'s prefix', function () {
|
||||
}
|
||||
});
|
||||
|
||||
it('shows console unavailable without the terminal shell wrapper', function () {
|
||||
it('defaults to a system console theme that follows the page color mode', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
foreach ([$terminalView, $consoleView] as $view) {
|
||||
expect($view)
|
||||
->toContain("consoleTheme: 'system'")
|
||||
->toContain("? savedTheme : 'system'");
|
||||
}
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("'system': createSystemTerminalTheme()")
|
||||
->toContain('new MutationObserver')
|
||||
->toContain("attributeFilter: ['class', 'data-theme']")
|
||||
->and($appCss)
|
||||
->toContain('[data-console-theme="system"]')
|
||||
->toContain('html.dark .application-console-shell[data-console-theme="system"]');
|
||||
});
|
||||
|
||||
it('uses the page color mode for the console theme selector', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
foreach ([$terminalView, $consoleView] as $view) {
|
||||
expect($view)
|
||||
->toContain('console-theme-selector')
|
||||
->toContain('border-neutral-200 bg-white')
|
||||
->toContain('dark:bg-[#111113]')
|
||||
->toContain('text-neutral-600 transition-colors')
|
||||
->toContain('dark:text-white/65');
|
||||
}
|
||||
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($appCss)
|
||||
->toContain('.console-theme-selector')
|
||||
->toContain('scrollbar-color: rgb(161 161 170) transparent')
|
||||
->toContain('html.dark .console-theme-selector')
|
||||
->toContain('scrollbar-color: rgb(255 255 255 / 0.28) transparent');
|
||||
});
|
||||
|
||||
it('shows terminal unavailable without the terminal shell wrapper', function () {
|
||||
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
|
||||
|
||||
$unavailableBranch = str($consoleView)
|
||||
@@ -52,14 +96,14 @@ it('shows console unavailable without the terminal shell wrapper', function () {
|
||||
expect($consoleView)
|
||||
->toContain('$consoleUnavailable')
|
||||
->toContain('@if ($consoleUnavailable)')
|
||||
->toContain('title="Console unavailable"')
|
||||
->toContain('title="Terminal unavailable"')
|
||||
->toContain('icon-name="browser-terminal"')
|
||||
// Empty state must not nest inside the themed terminal chrome.
|
||||
->not->toContain('No running containers');
|
||||
|
||||
expect($unavailableBranch)
|
||||
->toContain('<x-empty')
|
||||
->toContain('title="Console unavailable"')
|
||||
->toContain('title="Terminal unavailable"')
|
||||
->not->toContain('application-console-shell')
|
||||
->not->toContain('application-console-header')
|
||||
->not->toContain('Choose terminal theme');
|
||||
@@ -78,7 +122,7 @@ it('shows runtime logs unavailable without log viewer chrome', function () {
|
||||
->toContain('title="Runtime logs unavailable"')
|
||||
->toContain('icon-name="file-content"')
|
||||
->toContain('class="mt-4 w-full lg:mt-3"')
|
||||
->not->toContain('max-w-[1180px]')
|
||||
->toContain('application-settings-workspace')
|
||||
->not->toContain('title="No running containers"')
|
||||
->not->toContain('application-settings-form');
|
||||
|
||||
|
||||
@@ -18,3 +18,18 @@ it('still shows the user name and email inside the dropdown panel', function ()
|
||||
->toContain('truncate text-[13px] font-semibold text-black dark:text-fg">{{ $userName }}</div>')
|
||||
->toContain('{{ $userEmail }}');
|
||||
});
|
||||
|
||||
it('changes appearance from a submenu instead of navigating to a separate page', function () {
|
||||
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
|
||||
|
||||
expect($menu)
|
||||
->toContain('appearanceOpen: false')
|
||||
->toContain('@click.outside="open = false; appearanceOpen = false"')
|
||||
->toContain("theme: localStorage.getItem('theme') || 'dark'")
|
||||
->toContain('setTheme(type)')
|
||||
->toContain("['value' => 'light', 'label' => 'Light'")
|
||||
->toContain("['value' => 'system', 'label' => 'System'")
|
||||
->toContain("['value' => 'dark', 'label' => 'Dark'")
|
||||
->toContain('hover:bg-neutral-200 hover:text-neutral-950')
|
||||
->not->toContain("route('profile.appearance')");
|
||||
});
|
||||
|
||||