@@ -651,4 +651,8 @@ it('has emails', function (string $email) {
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
</laravel-boost-guidelines>
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
|
||||
Random other things you should remember:
|
||||
- App\Models\Application::team must return a relationship instance., always use team()
|
@@ -96,7 +96,11 @@ class GetContainersStatus
|
||||
}
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
if ($containerStatus === 'restarting') {
|
||||
$containerStatus = "restarting ($containerHealth)";
|
||||
} else {
|
||||
$containerStatus = "$containerStatus ($containerHealth)";
|
||||
}
|
||||
$labels = Arr::undot(format_docker_labels_to_json($labels));
|
||||
$applicationId = data_get($labels, 'coolify.applicationId');
|
||||
if ($applicationId) {
|
||||
@@ -386,19 +390,33 @@ class GetContainersStatus
|
||||
return null;
|
||||
}
|
||||
|
||||
// Aggregate status: if any container is running, app is running
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasExited = false;
|
||||
|
||||
foreach ($relevantStatuses as $status) {
|
||||
if (str($status)->contains('running')) {
|
||||
if (str($status)->contains('restarting')) {
|
||||
$hasRestarting = true;
|
||||
} elseif (str($status)->contains('running')) {
|
||||
$hasRunning = true;
|
||||
if (str($status)->contains('unhealthy')) {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
} elseif (str($status)->contains('exited')) {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasRestarting) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
}
|
||||
|
@@ -26,22 +26,22 @@ class ComplexStatusCheck
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
|
||||
$container = format_docker_command_output_to_json($container);
|
||||
if ($container->count() === 1) {
|
||||
$container = $container->first();
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
$containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false);
|
||||
$containers = format_docker_command_output_to_json($containers);
|
||||
|
||||
if ($containers->count() > 0) {
|
||||
$statusToSet = $this->aggregateContainerStatuses($application, $containers);
|
||||
|
||||
if ($is_main_server) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$application->update(['status' => "$containerStatus:$containerHealth"]);
|
||||
if ($statusFromDb !== $statusToSet) {
|
||||
$application->update(['status' => $statusToSet]);
|
||||
}
|
||||
} else {
|
||||
$additional_server = $application->additional_servers()->wherePivot('server_id', $server->id);
|
||||
$statusFromDb = $additional_server->first()->pivot->status;
|
||||
if ($statusFromDb !== $containerStatus) {
|
||||
$additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]);
|
||||
if ($statusFromDb !== $statusToSet) {
|
||||
$additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -57,4 +57,78 @@ class ComplexStatusCheck
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function aggregateContainerStatuses($application, $containers)
|
||||
{
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
$excludedContainers = collect();
|
||||
|
||||
if ($dockerComposeRaw) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
|
||||
foreach ($services as $serviceName => $serviceConfig) {
|
||||
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
|
||||
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
|
||||
|
||||
if ($excludeFromHc || $restartPolicy === 'no') {
|
||||
$excludedContainers->push($serviceName);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse, treat all containers as included
|
||||
}
|
||||
}
|
||||
|
||||
$hasRunning = false;
|
||||
$hasRestarting = false;
|
||||
$hasUnhealthy = false;
|
||||
$hasExited = false;
|
||||
$relevantContainerCount = 0;
|
||||
|
||||
foreach ($containers as $container) {
|
||||
$labels = data_get($container, 'Config.Labels', []);
|
||||
$serviceName = data_get($labels, 'com.docker.compose.service');
|
||||
|
||||
if ($serviceName && $excludedContainers->contains($serviceName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relevantContainerCount++;
|
||||
$containerStatus = data_get($container, 'State.Status');
|
||||
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
|
||||
|
||||
if ($containerStatus === 'restarting') {
|
||||
$hasRestarting = true;
|
||||
$hasUnhealthy = true;
|
||||
} elseif ($containerStatus === 'running') {
|
||||
$hasRunning = true;
|
||||
if ($containerHealth === 'unhealthy') {
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
} elseif ($containerStatus === 'exited') {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($relevantContainerCount === 0) {
|
||||
return 'running:healthy';
|
||||
}
|
||||
|
||||
if ($hasRestarting) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded:unhealthy';
|
||||
}
|
||||
|
||||
if ($hasRunning) {
|
||||
return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
|
||||
}
|
||||
|
||||
return 'exited:unhealthy';
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Actions\Stripe\CancelSubscription;
|
||||
use App\Actions\User\DeleteUserResources;
|
879
app/Console/Commands/Cloud/CloudFixSubscription.php
Normal file
879
app/Console/Commands/Cloud/CloudFixSubscription.php
Normal file
@@ -0,0 +1,879 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Cloud;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CloudFixSubscription extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'cloud:fix-subscription
|
||||
{--fix-canceled-subs : Fix canceled subscriptions in database}
|
||||
{--verify-all : Verify all active subscriptions against Stripe}
|
||||
{--fix-verified : Fix discrepancies found during verification}
|
||||
{--dry-run : Show what would be fixed without making changes}
|
||||
{--one : Only fix the first found subscription}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fix Cloud subscriptions';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
if ($this->option('verify-all')) {
|
||||
return $this->verifyAllActiveSubscriptions($stripe);
|
||||
}
|
||||
|
||||
if ($this->option('fix-canceled-subs') || $this->option('dry-run')) {
|
||||
return $this->fixCanceledSubscriptions($stripe);
|
||||
}
|
||||
|
||||
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
// CSV header
|
||||
fputcsv($out, [
|
||||
'team_id',
|
||||
'invoice_status',
|
||||
'stripe_customer_url',
|
||||
'stripe_subscription_id',
|
||||
'subscription_status',
|
||||
'subscription_url',
|
||||
'note',
|
||||
]);
|
||||
|
||||
foreach ($activeSubscribers as $team) {
|
||||
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
|
||||
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
|
||||
$stripeCustomerId = $team->subscription->stripe_customer_id;
|
||||
|
||||
if (! $stripeSubscriptionId && str($stripeInvoicePaid)->lower() != 'past_due') {
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$stripeInvoicePaid,
|
||||
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'Missing subscription ID while invoice not past_due',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $stripeSubscriptionId) {
|
||||
// No subscription ID and invoice is past_due, still record for visibility
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$stripeInvoicePaid,
|
||||
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'Missing subscription ID',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
|
||||
if ($subscription->status === 'active') {
|
||||
continue;
|
||||
}
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$stripeInvoicePaid,
|
||||
$stripeCustomerId ? "https://dashboard.stripe.com/customers/{$stripeCustomerId}" : null,
|
||||
$stripeSubscriptionId,
|
||||
$subscription->status,
|
||||
"https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}",
|
||||
'Subscription not active',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix canceled subscriptions in the database
|
||||
*/
|
||||
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$checkOne = $this->option('one');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info('DRY RUN MODE - No changes will be made');
|
||||
if ($checkOne) {
|
||||
$this->info('Checking only the first canceled subscription...');
|
||||
} else {
|
||||
$this->info('Checking for canceled subscriptions...');
|
||||
}
|
||||
} else {
|
||||
if ($checkOne) {
|
||||
$this->info('Checking and fixing only the first canceled subscription...');
|
||||
} else {
|
||||
$this->info('Checking and fixing canceled subscriptions...');
|
||||
}
|
||||
}
|
||||
|
||||
$teamsWithSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
$toFixCount = 0;
|
||||
$fixedCount = 0;
|
||||
$errors = [];
|
||||
$canceledSubscriptions = [];
|
||||
|
||||
foreach ($teamsWithSubscriptions as $team) {
|
||||
$subscription = $team->subscription;
|
||||
|
||||
if (! $subscription->stripe_subscription_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
if ($stripeSubscription->status === 'canceled') {
|
||||
$toFixCount++;
|
||||
|
||||
// Get team members' emails
|
||||
$memberEmails = $team->members->pluck('email')->toArray();
|
||||
|
||||
$canceledSubscriptions[] = [
|
||||
'team_id' => $team->id,
|
||||
'team_name' => $team->name,
|
||||
'customer_id' => $subscription->stripe_customer_id,
|
||||
'subscription_id' => $subscription->stripe_subscription_id,
|
||||
'status' => 'canceled',
|
||||
'member_emails' => $memberEmails,
|
||||
'subscription_model' => $subscription->toArray(),
|
||||
];
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn('Would fix canceled subscription:');
|
||||
$this->line(" Team ID: {$team->id}");
|
||||
$this->line(" Team Name: {$team->name}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
|
||||
$this->line(' Current Subscription Data:');
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$this->line(" - {$key}: null");
|
||||
} elseif (is_bool($value)) {
|
||||
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
|
||||
} else {
|
||||
$this->line(" - {$key}: {$value}");
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
} else {
|
||||
$this->warn("Found canceled subscription for Team ID: {$team->id}");
|
||||
|
||||
// Send internal notification with all details before fixing
|
||||
$notificationMessage = "Fixing canceled subscription:\n";
|
||||
$notificationMessage .= "Team ID: {$team->id}\n";
|
||||
$notificationMessage .= "Team Name: {$team->name}\n";
|
||||
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
|
||||
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
|
||||
$notificationMessage .= "Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}\n";
|
||||
$notificationMessage .= "Subscription Data:\n";
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$notificationMessage .= " - {$key}: null\n";
|
||||
} elseif (is_bool($value)) {
|
||||
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
|
||||
} else {
|
||||
$notificationMessage .= " - {$key}: {$value}\n";
|
||||
}
|
||||
}
|
||||
send_internal_notification($notificationMessage);
|
||||
|
||||
// Apply the same logic as customer.subscription.deleted webhook
|
||||
$team->subscriptionEnded();
|
||||
|
||||
$fixedCount++;
|
||||
$this->info(" ✓ Fixed subscription for Team ID: {$team->id}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
$this->line(" Subscription URL: https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}");
|
||||
}
|
||||
|
||||
// Break if --one flag is set
|
||||
if ($checkOne) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
if ($e->getStripeCode() === 'resource_missing') {
|
||||
$toFixCount++;
|
||||
|
||||
// Get team members' emails
|
||||
$memberEmails = $team->members->pluck('email')->toArray();
|
||||
|
||||
$canceledSubscriptions[] = [
|
||||
'team_id' => $team->id,
|
||||
'team_name' => $team->name,
|
||||
'customer_id' => $subscription->stripe_customer_id,
|
||||
'subscription_id' => $subscription->stripe_subscription_id,
|
||||
'status' => 'missing',
|
||||
'member_emails' => $memberEmails,
|
||||
'subscription_model' => $subscription->toArray(),
|
||||
];
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->error('Would fix missing subscription (not found in Stripe):');
|
||||
$this->line(" Team ID: {$team->id}");
|
||||
$this->line(" Team Name: {$team->name}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
$this->line(" Subscription ID (missing): {$subscription->stripe_subscription_id}");
|
||||
$this->line(' Current Subscription Data:');
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$this->line(" - {$key}: null");
|
||||
} elseif (is_bool($value)) {
|
||||
$this->line(" - {$key}: ".($value ? 'true' : 'false'));
|
||||
} else {
|
||||
$this->line(" - {$key}: {$value}");
|
||||
}
|
||||
}
|
||||
$this->newLine();
|
||||
} else {
|
||||
$this->error("Subscription not found in Stripe for Team ID: {$team->id}");
|
||||
|
||||
// Send internal notification with all details before fixing
|
||||
$notificationMessage = "Fixing missing subscription (not found in Stripe):\n";
|
||||
$notificationMessage .= "Team ID: {$team->id}\n";
|
||||
$notificationMessage .= "Team Name: {$team->name}\n";
|
||||
$notificationMessage .= 'Team Members: '.implode(', ', $memberEmails)."\n";
|
||||
$notificationMessage .= "Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}\n";
|
||||
$notificationMessage .= "Subscription ID (missing): {$subscription->stripe_subscription_id}\n";
|
||||
$notificationMessage .= "Subscription Data:\n";
|
||||
foreach ($subscription->getAttributes() as $key => $value) {
|
||||
if (is_null($value)) {
|
||||
$notificationMessage .= " - {$key}: null\n";
|
||||
} elseif (is_bool($value)) {
|
||||
$notificationMessage .= " - {$key}: ".($value ? 'true' : 'false')."\n";
|
||||
} else {
|
||||
$notificationMessage .= " - {$key}: {$value}\n";
|
||||
}
|
||||
}
|
||||
send_internal_notification($notificationMessage);
|
||||
|
||||
// Apply the same logic as customer.subscription.deleted webhook
|
||||
$team->subscriptionEnded();
|
||||
|
||||
$fixedCount++;
|
||||
$this->info(" ✓ Fixed missing subscription for Team ID: {$team->id}");
|
||||
$this->line(' Team Members: '.implode(', ', $memberEmails));
|
||||
$this->line(" Customer URL: https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}");
|
||||
}
|
||||
|
||||
// Break if --one flag is set
|
||||
if ($checkOne) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Team ID {$team->id}: ".$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Summary:');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info(" - Found {$toFixCount} canceled/missing subscriptions that would be fixed");
|
||||
|
||||
if ($toFixCount > 0) {
|
||||
$this->newLine();
|
||||
$this->comment('Run with --fix-canceled-subs to apply these changes');
|
||||
}
|
||||
} else {
|
||||
$this->info(" - Fixed {$fixedCount} canceled/missing subscriptions");
|
||||
}
|
||||
|
||||
if (! empty($errors)) {
|
||||
$this->newLine();
|
||||
$this->error('Errors encountered:');
|
||||
foreach ($errors as $error) {
|
||||
$this->error(" - {$error}");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify all active subscriptions against Stripe API
|
||||
*/
|
||||
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$shouldFix = $this->option('fix-verified');
|
||||
|
||||
$this->info('Verifying all active subscriptions against Stripe...');
|
||||
if ($isDryRun) {
|
||||
$this->info('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
if ($shouldFix && ! $isDryRun) {
|
||||
$this->warn('FIX MODE - Discrepancies will be corrected');
|
||||
}
|
||||
|
||||
// Get all teams with active subscriptions
|
||||
$teamsWithActiveSubscriptions = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
$totalCount = $teamsWithActiveSubscriptions->count();
|
||||
|
||||
$this->info("Found {$totalCount} teams with active subscriptions in database");
|
||||
$this->newLine();
|
||||
|
||||
$out = fopen('php://output', 'w');
|
||||
|
||||
// CSV header
|
||||
fputcsv($out, [
|
||||
'team_id',
|
||||
'team_name',
|
||||
'customer_id',
|
||||
'subscription_id',
|
||||
'db_status',
|
||||
'stripe_status',
|
||||
'action',
|
||||
'member_emails',
|
||||
'customer_url',
|
||||
'subscription_url',
|
||||
]);
|
||||
|
||||
$stats = [
|
||||
'total' => $totalCount,
|
||||
'valid_active' => 0,
|
||||
'valid_past_due' => 0,
|
||||
'canceled' => 0,
|
||||
'missing' => 0,
|
||||
'invalid' => 0,
|
||||
'fixed' => 0,
|
||||
'errors' => 0,
|
||||
];
|
||||
|
||||
$processedCount = 0;
|
||||
|
||||
foreach ($teamsWithActiveSubscriptions as $team) {
|
||||
$subscription = $team->subscription;
|
||||
$memberEmails = $team->members->pluck('email')->toArray();
|
||||
|
||||
// Database state
|
||||
$dbStatus = 'active';
|
||||
if ($subscription->stripe_past_due) {
|
||||
$dbStatus = 'past_due';
|
||||
}
|
||||
|
||||
$stripeStatus = null;
|
||||
$action = 'none';
|
||||
|
||||
if (! $subscription->stripe_subscription_id) {
|
||||
$this->line("Team {$team->id}: Missing subscription ID, searching in Stripe...");
|
||||
|
||||
$foundResult = null;
|
||||
$searchMethod = null;
|
||||
|
||||
// Search by customer ID
|
||||
if ($subscription->stripe_customer_id) {
|
||||
$this->line(" → Searching by customer ID: {$subscription->stripe_customer_id}");
|
||||
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
|
||||
if ($foundResult) {
|
||||
$searchMethod = $foundResult['method'];
|
||||
}
|
||||
} else {
|
||||
$this->line(' → No customer ID available');
|
||||
}
|
||||
|
||||
// Search by emails if not found
|
||||
if (! $foundResult && count($memberEmails) > 0) {
|
||||
$foundResult = $this->searchSubscriptionsByEmails($stripe, $memberEmails);
|
||||
if ($foundResult) {
|
||||
$searchMethod = $foundResult['method'];
|
||||
|
||||
// Update customer ID if different
|
||||
if (isset($foundResult['customer_id']) && $subscription->stripe_customer_id !== $foundResult['customer_id']) {
|
||||
if ($isDryRun) {
|
||||
$this->warn(" ⚠ Would update customer ID from {$subscription->stripe_customer_id} to {$foundResult['customer_id']}");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update(['stripe_customer_id' => $foundResult['customer_id']]);
|
||||
$this->info(" ✓ Updated customer ID to {$foundResult['customer_id']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($foundResult && isset($foundResult['subscription'])) {
|
||||
// Check if it's an active/past_due subscription
|
||||
if (in_array($foundResult['status'], ['active', 'past_due'])) {
|
||||
// Found an active subscription, handle update
|
||||
$result = $this->handleFoundSubscription(
|
||||
$team,
|
||||
$subscription,
|
||||
$foundResult['subscription'],
|
||||
$searchMethod,
|
||||
$isDryRun,
|
||||
$shouldFix,
|
||||
$stats
|
||||
);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$result['id'],
|
||||
$dbStatus,
|
||||
$result['status'],
|
||||
$result['action'],
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$result['url'],
|
||||
]);
|
||||
} else {
|
||||
// Found subscription but it's canceled/expired - needs to be deactivated
|
||||
$this->warn(" → Found {$foundResult['status']} subscription {$foundResult['subscription']->id} - needs deactivation");
|
||||
|
||||
$result = $this->handleMissingSubscription($team, $subscription, $foundResult['status'], $isDryRun, $shouldFix, $stats);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$foundResult['subscription']->id,
|
||||
$dbStatus,
|
||||
$foundResult['status'],
|
||||
'needs_fix',
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// No subscription found at all
|
||||
$this->line(' → No subscription found');
|
||||
|
||||
$stripeStatus = 'not_found';
|
||||
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
'N/A',
|
||||
$dbStatus,
|
||||
$result['status'],
|
||||
$result['action'],
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
'N/A',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// First validate the subscription ID format
|
||||
if (! str_starts_with($subscription->stripe_subscription_id, 'sub_')) {
|
||||
$this->warn(" ⚠ Invalid subscription ID format (doesn't start with 'sub_')");
|
||||
}
|
||||
|
||||
try {
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
$stripeStatus = $stripeSubscription->status;
|
||||
|
||||
// Determine if action is needed
|
||||
switch ($stripeStatus) {
|
||||
case 'active':
|
||||
$stats['valid_active']++;
|
||||
$action = 'valid';
|
||||
break;
|
||||
|
||||
case 'past_due':
|
||||
$stats['valid_past_due']++;
|
||||
$action = 'valid';
|
||||
// Ensure past_due flag is set
|
||||
if (! $subscription->stripe_past_due) {
|
||||
if ($isDryRun) {
|
||||
$this->info("Would set stripe_past_due=true for Team {$team->id}");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update(['stripe_past_due' => true]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'canceled':
|
||||
case 'incomplete_expired':
|
||||
case 'unpaid':
|
||||
case 'incomplete':
|
||||
$stats['canceled']++;
|
||||
$action = 'needs_fix';
|
||||
|
||||
// Only output problematic subscriptions
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
$stripeStatus,
|
||||
$action,
|
||||
implode(', ', $memberEmails),
|
||||
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
|
||||
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
|
||||
]);
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info("Would deactivate subscription for Team {$team->id} - status: {$stripeStatus}");
|
||||
} elseif ($shouldFix) {
|
||||
$this->fixSubscription($team, $subscription, $stripeStatus);
|
||||
$stats['fixed']++;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$stats['invalid']++;
|
||||
$action = 'unknown';
|
||||
|
||||
// Only output problematic subscriptions
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
$stripeStatus,
|
||||
$action,
|
||||
implode(', ', $memberEmails),
|
||||
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
|
||||
"https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}",
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||
$this->error(' → Error: '.$e->getMessage());
|
||||
|
||||
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
|
||||
// Subscription doesn't exist, try to find by customer ID
|
||||
$this->warn(" → Subscription not found, checking customer's subscriptions...");
|
||||
|
||||
$foundResult = null;
|
||||
if ($subscription->stripe_customer_id) {
|
||||
$foundResult = $this->searchSubscriptionsByCustomer($stripe, $subscription->stripe_customer_id);
|
||||
}
|
||||
|
||||
if ($foundResult && isset($foundResult['subscription']) && in_array($foundResult['status'], ['active', 'past_due'])) {
|
||||
// Found an active subscription with different ID
|
||||
$this->warn(" → ID mismatch! DB: {$subscription->stripe_subscription_id}, Stripe: {$foundResult['subscription']->id}");
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
"WRONG ID: {$subscription->stripe_subscription_id} → {$foundResult['subscription']->id}",
|
||||
$dbStatus,
|
||||
$foundResult['status'],
|
||||
'id_mismatch',
|
||||
implode(', ', $memberEmails),
|
||||
"https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}",
|
||||
"https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}",
|
||||
]);
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn(" → Would update subscription ID to {$foundResult['subscription']->id}");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update([
|
||||
'stripe_subscription_id' => $foundResult['subscription']->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => $foundResult['status'] === 'past_due',
|
||||
]);
|
||||
$stats['fixed']++;
|
||||
$this->info(' → Updated subscription ID');
|
||||
}
|
||||
|
||||
$stats[$foundResult['status'] === 'active' ? 'valid_active' : 'valid_past_due']++;
|
||||
} else {
|
||||
// No active subscription found
|
||||
$stripeStatus = $foundResult ? $foundResult['status'] : 'not_found';
|
||||
$result = $this->handleMissingSubscription($team, $subscription, $stripeStatus, $isDryRun, $shouldFix, $stats);
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
$result['status'],
|
||||
$result['action'],
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$foundResult && isset($foundResult['subscription']) ? "https://dashboard.stripe.com/subscriptions/{$foundResult['subscription']->id}" : 'N/A',
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Other API error
|
||||
$stats['errors']++;
|
||||
$this->error(' → API Error - not marking as deleted');
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
'error: '.$e->getStripeCode(),
|
||||
'error',
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' → Unexpected error: '.$e->getMessage());
|
||||
$stats['errors']++;
|
||||
|
||||
fputcsv($out, [
|
||||
$team->id,
|
||||
$team->name,
|
||||
$subscription->stripe_customer_id,
|
||||
$subscription->stripe_subscription_id,
|
||||
$dbStatus,
|
||||
'error',
|
||||
'error',
|
||||
implode(', ', $memberEmails),
|
||||
$subscription->stripe_customer_id ? "https://dashboard.stripe.com/customers/{$subscription->stripe_customer_id}" : 'N/A',
|
||||
$subscription->stripe_subscription_id ? "https://dashboard.stripe.com/subscriptions/{$subscription->stripe_subscription_id}" : 'N/A',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$processedCount++;
|
||||
if ($processedCount % 100 === 0) {
|
||||
$this->info("Processed {$processedCount}/{$totalCount} subscriptions...");
|
||||
}
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
|
||||
// Print summary
|
||||
$this->newLine(2);
|
||||
$this->info('=== Verification Summary ===');
|
||||
$this->info("Total subscriptions checked: {$stats['total']}");
|
||||
$this->newLine();
|
||||
|
||||
$this->info('Valid subscriptions in Stripe:');
|
||||
$this->line(" - Active: {$stats['valid_active']}");
|
||||
$this->line(" - Past Due: {$stats['valid_past_due']}");
|
||||
$validTotal = $stats['valid_active'] + $stats['valid_past_due'];
|
||||
$this->info(" Total valid: {$validTotal}");
|
||||
|
||||
$this->newLine();
|
||||
$this->warn('Invalid subscriptions:');
|
||||
$this->line(" - Canceled/Expired: {$stats['canceled']}");
|
||||
$this->line(" - Missing/Not Found: {$stats['missing']}");
|
||||
$this->line(" - Unknown status: {$stats['invalid']}");
|
||||
$invalidTotal = $stats['canceled'] + $stats['missing'] + $stats['invalid'];
|
||||
$this->warn(" Total invalid: {$invalidTotal}");
|
||||
|
||||
if ($stats['errors'] > 0) {
|
||||
$this->newLine();
|
||||
$this->error("Errors encountered: {$stats['errors']}");
|
||||
}
|
||||
|
||||
if ($shouldFix && ! $isDryRun) {
|
||||
$this->newLine();
|
||||
$this->info("Fixed subscriptions: {$stats['fixed']}");
|
||||
} elseif ($invalidTotal > 0 && ! $shouldFix) {
|
||||
$this->newLine();
|
||||
$this->comment('Run with --fix-verified to fix the discrepancies');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix a subscription based on its status
|
||||
*/
|
||||
private function fixSubscription($team, $subscription, $status)
|
||||
{
|
||||
$message = "Fixing subscription for Team ID: {$team->id} (Status: {$status})\n";
|
||||
$message .= "Team Name: {$team->name}\n";
|
||||
$message .= "Customer ID: {$subscription->stripe_customer_id}\n";
|
||||
$message .= "Subscription ID: {$subscription->stripe_subscription_id}\n";
|
||||
|
||||
send_internal_notification($message);
|
||||
|
||||
// Call the team's subscription ended method which properly cleans up
|
||||
$team->subscriptionEnded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for subscriptions by customer ID
|
||||
*/
|
||||
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
|
||||
{
|
||||
try {
|
||||
$subscriptions = $stripe->subscriptions->all([
|
||||
'customer' => $customerId,
|
||||
'limit' => 10,
|
||||
'status' => 'all',
|
||||
]);
|
||||
|
||||
$this->line(' → Found '.count($subscriptions->data).' subscription(s) for customer');
|
||||
|
||||
// Look for active/past_due first
|
||||
foreach ($subscriptions->data as $sub) {
|
||||
$this->line(" - Subscription {$sub->id}: status={$sub->status}");
|
||||
if (in_array($sub->status, ['active', 'past_due'])) {
|
||||
$this->info(" ✓ Found active/past_due subscription: {$sub->id}");
|
||||
|
||||
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// If not requiring active and there are subscriptions, return first one
|
||||
if (! $requireActive && count($subscriptions->data) > 0) {
|
||||
$sub = $subscriptions->data[0];
|
||||
$this->warn(" ⚠ Only found {$sub->status} subscription: {$sub->id}");
|
||||
|
||||
return ['subscription' => $sub, 'status' => $sub->status, 'method' => 'customer_id_first'];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
$this->error(' → Error searching by customer ID: '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for subscriptions by team member emails
|
||||
*/
|
||||
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
|
||||
{
|
||||
$this->line(' → Searching by team member emails...');
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$this->line(" → Checking email: {$email}");
|
||||
|
||||
try {
|
||||
$customers = $stripe->customers->all([
|
||||
'email' => $email,
|
||||
'limit' => 5,
|
||||
]);
|
||||
|
||||
if (count($customers->data) === 0) {
|
||||
$this->line(' - No customers found');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(' - Found '.count($customers->data).' customer(s)');
|
||||
|
||||
foreach ($customers->data as $customer) {
|
||||
$this->line(" - Checking customer {$customer->id}");
|
||||
|
||||
$result = $this->searchSubscriptionsByCustomer($stripe, $customer->id, true);
|
||||
if ($result) {
|
||||
$result['method'] = "email:{$email}";
|
||||
$result['customer_id'] = $customer->id;
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" - Error searching for email {$email}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle found subscription update (only for active/past_due subscriptions)
|
||||
*/
|
||||
private function handleFoundSubscription($team, $subscription, $foundSub, $searchMethod, $isDryRun, $shouldFix, &$stats)
|
||||
{
|
||||
$stripeStatus = $foundSub->status;
|
||||
$this->info(" ✓ FOUND active/past_due subscription {$foundSub->id} (status: {$stripeStatus})");
|
||||
|
||||
// Only update if it's active or past_due
|
||||
if (! in_array($stripeStatus, ['active', 'past_due'])) {
|
||||
$this->error(" ERROR: handleFoundSubscription called with {$stripeStatus} subscription!");
|
||||
|
||||
return [
|
||||
'id' => $foundSub->id,
|
||||
'status' => $stripeStatus,
|
||||
'action' => 'error',
|
||||
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
|
||||
];
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->warn(" → Would update subscription ID to {$foundSub->id} (status: {$stripeStatus})");
|
||||
} elseif ($shouldFix) {
|
||||
$subscription->update([
|
||||
'stripe_subscription_id' => $foundSub->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => $stripeStatus === 'past_due',
|
||||
]);
|
||||
$stats['fixed']++;
|
||||
$this->info(" → Updated subscription ID to {$foundSub->id}");
|
||||
}
|
||||
|
||||
// Update stats
|
||||
$stats[$stripeStatus === 'active' ? 'valid_active' : 'valid_past_due']++;
|
||||
|
||||
return [
|
||||
'id' => "FOUND: {$foundSub->id}",
|
||||
'status' => $stripeStatus,
|
||||
'action' => "will_update (via {$searchMethod})",
|
||||
'url' => "https://dashboard.stripe.com/subscriptions/{$foundSub->id}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle missing subscription
|
||||
*/
|
||||
private function handleMissingSubscription($team, $subscription, $status, $isDryRun, $shouldFix, &$stats)
|
||||
{
|
||||
$stats['missing']++;
|
||||
|
||||
if ($isDryRun) {
|
||||
$statusMsg = $status !== 'not_found' ? "status: {$status}" : 'no subscription found in Stripe';
|
||||
$this->warn(" → Would deactivate subscription - {$statusMsg}");
|
||||
} elseif ($shouldFix) {
|
||||
$this->fixSubscription($team, $subscription, $status);
|
||||
$stats['fixed']++;
|
||||
$this->info(' → Deactivated subscription');
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => 'N/A',
|
||||
'status' => $status,
|
||||
'action' => 'needs_fix',
|
||||
'url' => 'N/A',
|
||||
];
|
||||
}
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CloudCheckSubscription extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'cloud:check-subscription';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Check Cloud subscriptions';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$activeSubscribers = Team::whereRelation('subscription', 'stripe_invoice_paid', true)->get();
|
||||
foreach ($activeSubscribers as $team) {
|
||||
$stripeSubscriptionId = $team->subscription->stripe_subscription_id;
|
||||
$stripeInvoicePaid = $team->subscription->stripe_invoice_paid;
|
||||
$stripeCustomerId = $team->subscription->stripe_customer_id;
|
||||
if (! $stripeSubscriptionId) {
|
||||
echo "Team {$team->id} has no subscription, but invoice status is: {$stripeInvoicePaid}\n";
|
||||
echo "Link on Stripe: https://dashboard.stripe.com/customers/{$stripeCustomerId}\n";
|
||||
|
||||
continue;
|
||||
}
|
||||
$subscription = $stripe->subscriptions->retrieve($stripeSubscriptionId);
|
||||
if ($subscription->status === 'active') {
|
||||
continue;
|
||||
}
|
||||
echo "Subscription {$stripeSubscriptionId} is not active ({$subscription->status})\n";
|
||||
echo "Link on Stripe: https://dashboard.stripe.com/subscriptions/{$stripeSubscriptionId}\n";
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CloudCleanupSubscriptions extends Command
|
||||
{
|
||||
protected $signature = 'cloud:cleanup-subs';
|
||||
|
||||
protected $description = 'Cleanup subcriptions teams';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
if (! isCloud()) {
|
||||
$this->error('This command can only be run on cloud');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->info('Cleaning up subcriptions teams');
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
|
||||
$teams = Team::all()->filter(function ($team) {
|
||||
return $team->id !== 0;
|
||||
})->sortBy('id');
|
||||
foreach ($teams as $team) {
|
||||
if ($team) {
|
||||
$this->info("Checking team {$team->id}");
|
||||
}
|
||||
if (! data_get($team, 'subscription')) {
|
||||
$this->disableServers($team);
|
||||
|
||||
continue;
|
||||
}
|
||||
// If the team has no subscription id and the invoice is paid, we need to reset the invoice paid status
|
||||
if (! (data_get($team, 'subscription.stripe_subscription_id'))) {
|
||||
$this->info("Resetting invoice paid status for team {$team->id}");
|
||||
|
||||
$team->subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
$this->disableServers($team);
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$subscription = $stripe->subscriptions->retrieve(data_get($team, 'subscription.stripe_subscription_id'), []);
|
||||
$status = data_get($subscription, 'status');
|
||||
if ($status === 'active') {
|
||||
$team->subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_trial_already_ended' => false,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->info('Subscription status: '.$status);
|
||||
$this->info('Subscription id: '.data_get($team, 'subscription.stripe_subscription_id'));
|
||||
$confirm = $this->confirm('Do you want to cancel the subscription?', true);
|
||||
if (! $confirm) {
|
||||
$this->info("Skipping team {$team->id}");
|
||||
} else {
|
||||
$this->info("Cancelling subscription for team {$team->id}");
|
||||
$team->subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
'stripe_subscription_id' => null,
|
||||
]);
|
||||
$this->disableServers($team);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function disableServers(Team $team)
|
||||
{
|
||||
foreach ($team->servers as $server) {
|
||||
if ($server->settings->is_usable === true || $server->settings->is_reachable === true || $server->ip !== '1.2.3.4') {
|
||||
$this->info("Disabling server {$server->id} {$server->name}");
|
||||
$server->settings()->update([
|
||||
'is_usable' => false,
|
||||
'is_reachable' => false,
|
||||
]);
|
||||
$server->update([
|
||||
'ip' => '1.2.3.4',
|
||||
]);
|
||||
|
||||
ServerReachabilityChanged::dispatch($server);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
35
app/Events/ApplicationConfigurationChanged.php
Normal file
35
app/Events/ApplicationConfigurationChanged.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ApplicationConfigurationChanged implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public ?int $teamId = null;
|
||||
|
||||
public function __construct($teamId = null)
|
||||
{
|
||||
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
}
|
||||
$this->teamId = $teamId;
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
if (is_null($this->teamId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
}
|
@@ -2532,8 +2532,11 @@ class ApplicationsController extends Controller
|
||||
if ($env->is_shown_once != $request->is_shown_once) {
|
||||
$env->is_shown_once = $request->is_shown_once;
|
||||
}
|
||||
if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) {
|
||||
$env->is_buildtime_only = $request->is_buildtime_only;
|
||||
if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
|
||||
$env->is_runtime = $request->is_runtime;
|
||||
}
|
||||
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
|
||||
$env->is_buildtime = $request->is_buildtime;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
@@ -2559,8 +2562,11 @@ class ApplicationsController extends Controller
|
||||
if ($env->is_shown_once != $request->is_shown_once) {
|
||||
$env->is_shown_once = $request->is_shown_once;
|
||||
}
|
||||
if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) {
|
||||
$env->is_buildtime_only = $request->is_buildtime_only;
|
||||
if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) {
|
||||
$env->is_runtime = $request->is_runtime;
|
||||
}
|
||||
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
|
||||
$env->is_buildtime = $request->is_buildtime;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
@@ -2723,8 +2729,11 @@ class ApplicationsController extends Controller
|
||||
if ($env->is_shown_once != $item->get('is_shown_once')) {
|
||||
$env->is_shown_once = $item->get('is_shown_once');
|
||||
}
|
||||
if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) {
|
||||
$env->is_buildtime_only = $item->get('is_buildtime_only');
|
||||
if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
|
||||
$env->is_runtime = $item->get('is_runtime');
|
||||
}
|
||||
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
|
||||
$env->is_buildtime = $item->get('is_buildtime');
|
||||
}
|
||||
$env->save();
|
||||
} else {
|
||||
@@ -2735,7 +2744,8 @@ class ApplicationsController extends Controller
|
||||
'is_literal' => $is_literal,
|
||||
'is_multiline' => $is_multi_line,
|
||||
'is_shown_once' => $is_shown_once,
|
||||
'is_buildtime_only' => $item->get('is_buildtime_only', false),
|
||||
'is_runtime' => $item->get('is_runtime', true),
|
||||
'is_buildtime' => $item->get('is_buildtime', true),
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
@@ -2753,8 +2763,11 @@ class ApplicationsController extends Controller
|
||||
if ($env->is_shown_once != $item->get('is_shown_once')) {
|
||||
$env->is_shown_once = $item->get('is_shown_once');
|
||||
}
|
||||
if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) {
|
||||
$env->is_buildtime_only = $item->get('is_buildtime_only');
|
||||
if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) {
|
||||
$env->is_runtime = $item->get('is_runtime');
|
||||
}
|
||||
if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) {
|
||||
$env->is_buildtime = $item->get('is_buildtime');
|
||||
}
|
||||
$env->save();
|
||||
} else {
|
||||
@@ -2765,7 +2778,8 @@ class ApplicationsController extends Controller
|
||||
'is_literal' => $is_literal,
|
||||
'is_multiline' => $is_multi_line,
|
||||
'is_shown_once' => $is_shown_once,
|
||||
'is_buildtime_only' => $item->get('is_buildtime_only', false),
|
||||
'is_runtime' => $item->get('is_runtime', true),
|
||||
'is_buildtime' => $item->get('is_buildtime', true),
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
@@ -2904,7 +2918,8 @@ class ApplicationsController extends Controller
|
||||
'is_literal' => $request->is_literal ?? false,
|
||||
'is_multiline' => $request->is_multiline ?? false,
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_buildtime_only' => $request->is_buildtime_only ?? false,
|
||||
'is_runtime' => $request->is_runtime ?? true,
|
||||
'is_buildtime' => $request->is_buildtime ?? true,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
@@ -2927,7 +2942,8 @@ class ApplicationsController extends Controller
|
||||
'is_literal' => $request->is_literal ?? false,
|
||||
'is_multiline' => $request->is_multiline ?? false,
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_buildtime_only' => $request->is_buildtime_only ?? false,
|
||||
'is_runtime' => $request->is_runtime ?? true,
|
||||
'is_buildtime' => $request->is_buildtime ?? true,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
@@ -3364,11 +3380,12 @@ class ApplicationsController extends Controller
|
||||
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
|
||||
$errors = [];
|
||||
$fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) {
|
||||
$domain = trim($domain);
|
||||
if (filter_var($domain, FILTER_VALIDATE_URL) === false) {
|
||||
$errors[] = 'Invalid domain: '.$domain;
|
||||
}
|
||||
|
||||
return str($domain)->trim()->lower();
|
||||
return str($domain)->lower();
|
||||
});
|
||||
if (count($errors) > 0) {
|
||||
return response()->json([
|
||||
|
@@ -9,11 +9,15 @@ use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Enums\NewDatabaseTypes;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DatabasesController extends Controller
|
||||
@@ -79,13 +83,88 @@ class DatabasesController extends Controller
|
||||
foreach ($projects as $project) {
|
||||
$databases = $databases->merge($project->databases());
|
||||
}
|
||||
$databases = $databases->map(function ($database) {
|
||||
|
||||
$databaseIds = $databases->pluck('id')->toArray();
|
||||
|
||||
$backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log')
|
||||
->whereIn('database_id', $databaseIds)
|
||||
->get()
|
||||
->groupBy('database_id');
|
||||
|
||||
$databases = $databases->map(function ($database) use ($backupConfigs) {
|
||||
$database->backup_configs = $backupConfigs->get($database->id, collect())->values();
|
||||
|
||||
return $this->removeSensitiveData($database);
|
||||
});
|
||||
|
||||
return response()->json($databases);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get',
|
||||
description: 'Get backups details by database UUID.',
|
||||
path: '/databases/{uuid}/backups',
|
||||
operationId: 'get-database-backups-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all backups for a database',
|
||||
content: new OA\JsonContent(
|
||||
type: 'string',
|
||||
example: 'Content is very complex. Will be implemented later.',
|
||||
),
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function database_backup_details_uuid(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 404);
|
||||
}
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $database);
|
||||
|
||||
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get();
|
||||
|
||||
return response()->json($backupConfig);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get',
|
||||
description: 'Get database by UUID.',
|
||||
@@ -248,6 +327,7 @@ class DatabasesController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// this check if the request is a valid json
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
@@ -499,7 +579,8 @@ class DatabasesController extends Controller
|
||||
$whatToDoWithDatabaseProxy = 'start';
|
||||
}
|
||||
|
||||
$database->update($request->all());
|
||||
// Only update database fields, not backup configuration
|
||||
$database->update($request->only($allowedFields));
|
||||
|
||||
if ($whatToDoWithDatabaseProxy === 'start') {
|
||||
StartDatabaseProxy::dispatch($database);
|
||||
@@ -512,6 +593,197 @@ class DatabasesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update',
|
||||
description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID',
|
||||
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
|
||||
operationId: 'update-database-backup',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'scheduled_backup_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the backup configuration.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Database backup configuration data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'],
|
||||
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'],
|
||||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'],
|
||||
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
|
||||
'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
|
||||
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
|
||||
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
|
||||
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
|
||||
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Database backup configuration updated',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_backup(Request $request)
|
||||
{
|
||||
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
// this check if the request is a valid json
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'save_s3' => 'boolean',
|
||||
'backup_now' => 'boolean|nullable',
|
||||
'enabled' => 'boolean',
|
||||
'dump_all' => 'boolean',
|
||||
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
|
||||
'databases_to_backup' => 'string|nullable',
|
||||
'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly',
|
||||
'database_backup_retention_amount_locally' => 'integer|min:0',
|
||||
'database_backup_retention_days_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'integer|min:0',
|
||||
'database_backup_retention_amount_s3' => 'integer|min:0',
|
||||
'database_backup_retention_days_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'integer|min:0',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! $request->uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 404);
|
||||
}
|
||||
|
||||
// Validate scheduled_backup_uuid is provided
|
||||
if (! $request->scheduled_backup_uuid) {
|
||||
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$uuid = $request->uuid;
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
|
||||
], 422);
|
||||
}
|
||||
if ($request->filled('s3_storage_uuid')) {
|
||||
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
|
||||
if (! $existsInTeam) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
|
||||
->where('uuid', $request->scheduled_backup_uuid)
|
||||
->first();
|
||||
if (! $backupConfig) {
|
||||
return response()->json(['message' => 'Backup config not found.'], 404);
|
||||
}
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']);
|
||||
if (! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$backupData = $request->only($backupConfigFields);
|
||||
|
||||
// Convert s3_storage_uuid to s3_storage_id
|
||||
if (isset($backupData['s3_storage_uuid'])) {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
|
||||
if ($s3Storage) {
|
||||
$backupData['s3_storage_id'] = $s3Storage->id;
|
||||
} elseif ($request->boolean('save_s3')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
|
||||
], 422);
|
||||
}
|
||||
unset($backupData['s3_storage_uuid']);
|
||||
}
|
||||
|
||||
$backupConfig->update($backupData);
|
||||
|
||||
if ($request->backup_now) {
|
||||
dispatch(new DatabaseBackupJob($backupConfig));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Database backup configuration updated',
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create (PostgreSQL)',
|
||||
description: 'Create a new PostgreSQL database.',
|
||||
@@ -1630,6 +1902,344 @@ class DatabasesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete backup configuration',
|
||||
description: 'Deletes a backup configuration and all its executions.',
|
||||
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
|
||||
operationId: 'delete-backup-configuration-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the database',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'scheduled_backup_uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the backup configuration to delete',
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'delete_s3',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Whether to delete all backup files from S3',
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Backup configuration deleted.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Backup configuration not found.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_backup_by_uuid(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Validate scheduled_backup_uuid is provided
|
||||
if (! $request->scheduled_backup_uuid) {
|
||||
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
// Find the backup configuration by its UUID
|
||||
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
|
||||
->where('uuid', $request->scheduled_backup_uuid)
|
||||
->first();
|
||||
|
||||
if (! $backup) {
|
||||
return response()->json(['message' => 'Backup configuration not found.'], 404);
|
||||
}
|
||||
|
||||
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
// Get all executions for this backup configuration
|
||||
$executions = $backup->executions()->get();
|
||||
|
||||
// Delete all execution files (locally and optionally from S3)
|
||||
foreach ($executions as $execution) {
|
||||
if ($execution->filename) {
|
||||
deleteBackupsLocally($execution->filename, $database->destination->server);
|
||||
|
||||
if ($deleteS3 && $backup->s3) {
|
||||
deleteBackupsS3($execution->filename, $backup->s3);
|
||||
}
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
}
|
||||
|
||||
// Delete the backup configuration itself
|
||||
$backup->delete();
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Backup configuration and all executions deleted.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete backup execution',
|
||||
description: 'Deletes a specific backup execution.',
|
||||
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}',
|
||||
operationId: 'delete-backup-execution-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the database',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'scheduled_backup_uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the backup configuration',
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'execution_uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the backup execution to delete',
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'delete_s3',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Whether to delete the backup from S3',
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Backup execution deleted.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Backup execution not found.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_execution_by_uuid(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if (! $request->scheduled_backup_uuid) {
|
||||
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
|
||||
}
|
||||
if (! $request->execution_uuid) {
|
||||
return response()->json(['message' => 'Execution UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
// Find the backup configuration by its UUID
|
||||
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
|
||||
->where('uuid', $request->scheduled_backup_uuid)
|
||||
->first();
|
||||
|
||||
if (! $backup) {
|
||||
return response()->json(['message' => 'Backup configuration not found.'], 404);
|
||||
}
|
||||
|
||||
// Find the specific execution
|
||||
$execution = $backup->executions()->where('uuid', $request->execution_uuid)->first();
|
||||
if (! $execution) {
|
||||
return response()->json(['message' => 'Backup execution not found.'], 404);
|
||||
}
|
||||
|
||||
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
try {
|
||||
if ($execution->filename) {
|
||||
deleteBackupsLocally($execution->filename, $database->destination->server);
|
||||
|
||||
if ($deleteS3 && $backup->s3) {
|
||||
deleteBackupsS3($execution->filename, $backup->s3);
|
||||
}
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Backup execution deleted.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List backup executions',
|
||||
description: 'Get all executions for a specific backup configuration.',
|
||||
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions',
|
||||
operationId: 'list-backup-executions',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the database',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'scheduled_backup_uuid',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'UUID of the backup configuration',
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of backup executions',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'executions' => new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'filename' => ['type' => 'string'],
|
||||
'size' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
'message' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
description: 'Backup configuration not found.',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function list_backup_executions(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
// Validate scheduled_backup_uuid is provided
|
||||
if (! $request->scheduled_backup_uuid) {
|
||||
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
// Find the backup configuration by its UUID
|
||||
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
|
||||
->where('uuid', $request->scheduled_backup_uuid)
|
||||
->first();
|
||||
|
||||
if (! $backup) {
|
||||
return response()->json(['message' => 'Backup configuration not found.'], 404);
|
||||
}
|
||||
|
||||
// Get all executions for this backup configuration
|
||||
$executions = $backup->executions()
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->map(function ($execution) {
|
||||
return [
|
||||
'uuid' => $execution->uuid,
|
||||
'filename' => $execution->filename,
|
||||
'size' => $execution->size,
|
||||
'created_at' => $execution->created_at->toIso8601String(),
|
||||
'message' => $execution->message,
|
||||
'status' => $execution->status,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'executions' => $executions,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Start',
|
||||
description: 'Start database. `Post` request is also accepted.',
|
||||
|
661
app/Http/Controllers/Api/GithubController.php
Normal file
661
app/Http/Controllers/Api/GithubController.php
Normal file
@@ -0,0 +1,661 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class GithubController extends Controller
|
||||
{
|
||||
#[OA\Post(
|
||||
summary: 'Create GitHub App',
|
||||
description: 'Create a new GitHub app.',
|
||||
path: '/github-apps',
|
||||
operationId: 'create-github-app',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'GitHub app creation payload.',
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'Name of the GitHub app.'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'Organization to associate the app with.'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'API URL for the GitHub app (e.g., https://api.github.com).'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'HTML URL for the GitHub app (e.g., https://github.com).'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
|
||||
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID from GitHub.'],
|
||||
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID.'],
|
||||
'client_id' => ['type' => 'string', 'description' => 'GitHub OAuth App Client ID.'],
|
||||
'client_secret' => ['type' => 'string', 'description' => 'GitHub OAuth App Client Secret.'],
|
||||
'webhook_secret' => ['type' => 'string', 'description' => 'Webhook secret for GitHub webhooks.'],
|
||||
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
|
||||
],
|
||||
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'GitHub app created successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true],
|
||||
'api_url' => ['type' => 'string'],
|
||||
'html_url' => ['type' => 'string'],
|
||||
'custom_user' => ['type' => 'string'],
|
||||
'custom_port' => ['type' => 'integer'],
|
||||
'app_id' => ['type' => 'integer'],
|
||||
'installation_id' => ['type' => 'integer'],
|
||||
'client_id' => ['type' => 'string'],
|
||||
'private_key_id' => ['type' => 'integer'],
|
||||
'is_system_wide' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_github_app(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'organization',
|
||||
'api_url',
|
||||
'html_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'app_id',
|
||||
'installation_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
'private_key_uuid',
|
||||
'is_system_wide',
|
||||
];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'organization' => 'nullable|string|max:255',
|
||||
'api_url' => 'required|string|url',
|
||||
'html_url' => 'required|string|url',
|
||||
'custom_user' => 'nullable|string|max:255',
|
||||
'custom_port' => 'nullable|integer|min:1|max:65535',
|
||||
'app_id' => 'required|integer',
|
||||
'installation_id' => 'required|integer',
|
||||
'client_id' => 'required|string|max:255',
|
||||
'client_secret' => 'required|string',
|
||||
'webhook_secret' => 'required|string',
|
||||
'private_key_uuid' => 'required|string',
|
||||
'is_system_wide' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the private key belongs to the team
|
||||
$privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid'))
|
||||
->where('team_id', $teamId)
|
||||
->first();
|
||||
|
||||
if (! $privateKey) {
|
||||
return response()->json([
|
||||
'message' => 'Private key not found or does not belong to your team.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'uuid' => Str::uuid(),
|
||||
'name' => $request->input('name'),
|
||||
'organization' => $request->input('organization'),
|
||||
'api_url' => $request->input('api_url'),
|
||||
'html_url' => $request->input('html_url'),
|
||||
'custom_user' => $request->input('custom_user', 'git'),
|
||||
'custom_port' => $request->input('custom_port', 22),
|
||||
'app_id' => $request->input('app_id'),
|
||||
'installation_id' => $request->input('installation_id'),
|
||||
'client_id' => $request->input('client_id'),
|
||||
'client_secret' => $request->input('client_secret'),
|
||||
'webhook_secret' => $request->input('webhook_secret'),
|
||||
'private_key_id' => $privateKey->id,
|
||||
'is_public' => false,
|
||||
'team_id' => $teamId,
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$payload['is_system_wide'] = $request->input('is_system_wide', false);
|
||||
}
|
||||
|
||||
$githubApp = GithubApp::create($payload);
|
||||
|
||||
return response()->json($githubApp, 201);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/github-apps/{github_app_id}/repositories',
|
||||
summary: 'Load Repositories for a GitHub App',
|
||||
description: 'Fetch repositories from GitHub for a given GitHub app.',
|
||||
operationId: 'load-repositories',
|
||||
tags: ['GitHub Apps'],
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Repositories loaded successfully.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'repositories' => new OA\Items(
|
||||
type: 'array',
|
||||
items: new OA\Schema(type: 'object')
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function load_repositories($github_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$repositories = collect();
|
||||
$page = 1;
|
||||
$maxPages = 100; // Safety limit: max 10,000 repositories
|
||||
|
||||
while ($page <= $maxPages) {
|
||||
$response = Http::GitHub($githubApp->api_url, $token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get('/installation/repositories', [
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
return response()->json([
|
||||
'message' => $response->json()['message'] ?? 'Failed to load repositories',
|
||||
], $response->status());
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
$repos = $json['repositories'] ?? [];
|
||||
|
||||
if (empty($repos)) {
|
||||
break; // No more repositories to load
|
||||
}
|
||||
|
||||
$repositories = $repositories->concat($repos);
|
||||
$page++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'repositories' => $repositories->sortBy('name')->values(),
|
||||
]);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return response()->json(['message' => 'GitHub app not found'], 404);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches',
|
||||
summary: 'Load Branches for a GitHub Repository',
|
||||
description: 'Fetch branches from GitHub for a given repository.',
|
||||
operationId: 'load-branches',
|
||||
tags: ['GitHub Apps'],
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'owner',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
description: 'Repository owner'
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'repo',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string'),
|
||||
description: 'Repository name'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Branches loaded successfully.',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'branches' => new OA\Items(
|
||||
type: 'array',
|
||||
items: new OA\Schema(type: 'object')
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function load_branches($github_app_id, $owner, $repo)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
|
||||
$response = Http::GitHub($githubApp->api_url, $token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/repos/{$owner}/{$repo}/branches");
|
||||
|
||||
if ($response->status() !== 200) {
|
||||
return response()->json([
|
||||
'message' => 'Error loading branches from GitHub.',
|
||||
'error' => $response->json('message'),
|
||||
], $response->status());
|
||||
}
|
||||
|
||||
$branches = $response->json();
|
||||
|
||||
return response()->json([
|
||||
'branches' => $branches,
|
||||
]);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return response()->json(['message' => 'GitHub app not found'], 404);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a GitHub app.
|
||||
*/
|
||||
#[OA\Patch(
|
||||
path: '/github-apps/{github_app_id}',
|
||||
operationId: 'updateGithubApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
summary: 'Update GitHub App',
|
||||
description: 'Update an existing GitHub app.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'GitHub App name'],
|
||||
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'GitHub organization'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'GitHub API URL'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'GitHub HTML URL'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
|
||||
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID'],
|
||||
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID'],
|
||||
'client_id' => ['type' => 'string', 'description' => 'GitHub Client ID'],
|
||||
'client_secret' => ['type' => 'string', 'description' => 'GitHub Client Secret'],
|
||||
'webhook_secret' => ['type' => 'string', 'description' => 'GitHub Webhook Secret'],
|
||||
'private_key_uuid' => ['type' => 'string', 'description' => 'Private key UUID'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitHub app updated successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitHub app updated successfully'],
|
||||
'data' => ['type' => 'object', 'description' => 'Updated GitHub app data'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitHub app not found'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
]
|
||||
)]
|
||||
public function update_github_app(Request $request, $github_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
// Define allowed fields for update
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'organization',
|
||||
'api_url',
|
||||
'html_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'app_id',
|
||||
'installation_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
'private_key_uuid',
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$allowedFields[] = 'is_system_wide';
|
||||
}
|
||||
|
||||
$payload = $request->only($allowedFields);
|
||||
|
||||
// Validate the request
|
||||
$rules = [];
|
||||
if (isset($payload['name'])) {
|
||||
$rules['name'] = 'string';
|
||||
}
|
||||
if (isset($payload['organization'])) {
|
||||
$rules['organization'] = 'nullable|string';
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$rules['api_url'] = 'url';
|
||||
}
|
||||
if (isset($payload['html_url'])) {
|
||||
$rules['html_url'] = 'url';
|
||||
}
|
||||
if (isset($payload['custom_user'])) {
|
||||
$rules['custom_user'] = 'string';
|
||||
}
|
||||
if (isset($payload['custom_port'])) {
|
||||
$rules['custom_port'] = 'integer|min:1|max:65535';
|
||||
}
|
||||
if (isset($payload['app_id'])) {
|
||||
$rules['app_id'] = 'integer';
|
||||
}
|
||||
if (isset($payload['installation_id'])) {
|
||||
$rules['installation_id'] = 'integer';
|
||||
}
|
||||
if (isset($payload['client_id'])) {
|
||||
$rules['client_id'] = 'string';
|
||||
}
|
||||
if (isset($payload['client_secret'])) {
|
||||
$rules['client_secret'] = 'string';
|
||||
}
|
||||
if (isset($payload['webhook_secret'])) {
|
||||
$rules['webhook_secret'] = 'string';
|
||||
}
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$rules['private_key_uuid'] = 'string|uuid';
|
||||
}
|
||||
if (! isCloud() && isset($payload['is_system_wide'])) {
|
||||
$rules['is_system_wide'] = 'boolean';
|
||||
}
|
||||
|
||||
$validator = customApiValidator($payload, $rules);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation error',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Handle private_key_uuid -> private_key_id conversion
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$privateKey = PrivateKey::where('team_id', $teamId)
|
||||
->where('uuid', $payload['private_key_uuid'])
|
||||
->first();
|
||||
|
||||
if (! $privateKey) {
|
||||
return response()->json([
|
||||
'message' => 'Private key not found or does not belong to your team',
|
||||
], 404);
|
||||
}
|
||||
|
||||
unset($payload['private_key_uuid']);
|
||||
$payload['private_key_id'] = $privateKey->id;
|
||||
}
|
||||
|
||||
// Update the GitHub app
|
||||
$githubApp->update($payload);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app updated successfully',
|
||||
'data' => $githubApp,
|
||||
]);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitHub app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a GitHub app.
|
||||
*/
|
||||
#[OA\Delete(
|
||||
path: '/github-apps/{github_app_id}',
|
||||
operationId: 'deleteGithubApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitHub Apps'],
|
||||
summary: 'Delete GitHub App',
|
||||
description: 'Delete a GitHub app if it\'s not being used by any applications.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'github_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitHub App ID'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitHub app deleted successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitHub app deleted successfully'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitHub app not found'),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Conflict - GitHub app is in use',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'This GitHub app is being used by 5 application(s). Please delete all applications first.'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_github_app($github_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
|
||||
// Check if the GitHub app is being used by any applications
|
||||
if ($githubApp->applications->isNotEmpty()) {
|
||||
$count = $githubApp->applications->count();
|
||||
|
||||
return response()->json([
|
||||
'message' => "This GitHub app is being used by {$count} application(s). Please delete all applications first.",
|
||||
], 409);
|
||||
}
|
||||
|
||||
$githubApp->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app deleted successfully',
|
||||
]);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitHub app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
@@ -179,6 +179,8 @@ class TeamController extends Controller
|
||||
$members = $team->members;
|
||||
$members->makeHidden([
|
||||
'pivot',
|
||||
'email_change_code',
|
||||
'email_change_code_expires_at',
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
@@ -264,6 +266,8 @@ class TeamController extends Controller
|
||||
$team = auth()->user()->currentTeam();
|
||||
$team->members->makeHidden([
|
||||
'pivot',
|
||||
'email_change_code',
|
||||
'email_change_code_expires_at',
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -74,8 +74,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$this->timeout = $backup->timeout;
|
||||
|
||||
$this->backup_log_uuid = (string) new Cuid2;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
@@ -288,6 +286,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->backup_dir = backup_dir().'/coolify'."/coolify-db-$ip";
|
||||
}
|
||||
foreach ($databasesToBackup as $database) {
|
||||
// Generate unique UUID for each database backup execution
|
||||
$attempts = 0;
|
||||
do {
|
||||
$this->backup_log_uuid = (string) new Cuid2;
|
||||
$exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists();
|
||||
$attempts++;
|
||||
if ($attempts >= 3 && $exists) {
|
||||
throw new \Exception('Unable to generate unique UUID for backup execution after 3 attempts');
|
||||
}
|
||||
} while ($exists);
|
||||
|
||||
$size = 0;
|
||||
try {
|
||||
if (str($databaseType)->contains('postgres')) {
|
||||
|
@@ -78,11 +78,11 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
// Server is reachable, check if Docker is available
|
||||
// $isUsable = $this->checkDockerAvailability();
|
||||
$isUsable = $this->checkDockerAvailability();
|
||||
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'is_usable' => $isUsable,
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
@@ -93,20 +93,66 @@ class StripeProcessJob implements ShouldQueue
|
||||
break;
|
||||
case 'invoice.paid':
|
||||
$customerId = data_get($data, 'customer');
|
||||
$invoiceAmount = data_get($data, 'amount_paid', 0);
|
||||
$subscriptionId = data_get($data, 'subscription');
|
||||
$planId = data_get($data, 'lines.data.0.plan.id');
|
||||
if (Str::contains($excludedPlans, $planId)) {
|
||||
// send_internal_notification('Subscription excluded.');
|
||||
break;
|
||||
}
|
||||
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
|
||||
if ($subscription) {
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
} else {
|
||||
if (! $subscription) {
|
||||
throw new \RuntimeException("No subscription found for customer: {$customerId}");
|
||||
}
|
||||
|
||||
if ($subscription->stripe_subscription_id) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
switch ($stripeSubscription->status) {
|
||||
case 'active':
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'past_due':
|
||||
$subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => true,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'canceled':
|
||||
case 'incomplete_expired':
|
||||
case 'unpaid':
|
||||
send_internal_notification(
|
||||
"Invoice paid for {$stripeSubscription->status} subscription. ".
|
||||
"Customer: {$customerId}, Amount: \${$invoiceAmount}"
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
|
||||
->delay(now()->addSeconds(20));
|
||||
break;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
|
||||
->delay(now()->addSeconds(20));
|
||||
|
||||
send_internal_notification(
|
||||
'Failed to verify subscription status in invoice.paid: '.$e->getMessage()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
VerifyStripeSubscriptionStatusJob::dispatch($subscription)
|
||||
->delay(now()->addSeconds(20));
|
||||
}
|
||||
break;
|
||||
case 'invoice.payment_failed':
|
||||
$customerId = data_get($data, 'customer');
|
||||
|
106
app/Jobs/VerifyStripeSubscriptionStatusJob.php
Normal file
106
app/Jobs/VerifyStripeSubscriptionStatusJob.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class VerifyStripeSubscriptionStatusJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public array $backoff = [10, 30, 60];
|
||||
|
||||
public function __construct(public Subscription $subscription)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// If no subscription ID yet, try to find it via customer
|
||||
if (! $this->subscription->stripe_subscription_id &&
|
||||
$this->subscription->stripe_customer_id) {
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$subscriptions = $stripe->subscriptions->all([
|
||||
'customer' => $this->subscription->stripe_customer_id,
|
||||
'limit' => 1,
|
||||
]);
|
||||
|
||||
if ($subscriptions->data) {
|
||||
$this->subscription->update([
|
||||
'stripe_subscription_id' => $subscriptions->data[0]->id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Continue without subscription ID
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->subscription->stripe_subscription_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||
$this->subscription->stripe_subscription_id
|
||||
);
|
||||
|
||||
switch ($stripeSubscription->status) {
|
||||
case 'active':
|
||||
$this->subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => false,
|
||||
'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'past_due':
|
||||
// Keep subscription active but mark as past_due
|
||||
$this->subscription->update([
|
||||
'stripe_invoice_paid' => true,
|
||||
'stripe_past_due' => true,
|
||||
'stripe_cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'canceled':
|
||||
case 'incomplete_expired':
|
||||
case 'unpaid':
|
||||
// Ensure subscription is marked as inactive
|
||||
$this->subscription->update([
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_past_due' => false,
|
||||
]);
|
||||
|
||||
// Trigger subscription ended logic if canceled
|
||||
if ($stripeSubscription->status === 'canceled') {
|
||||
$team = $this->subscription->team;
|
||||
if ($team) {
|
||||
$team->subscriptionEnded();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
send_internal_notification(
|
||||
'Unknown subscription status in VerifyStripeSubscriptionStatusJob: '.$stripeSubscription->status.
|
||||
' for customer: '.$this->subscription->stripe_customer_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
send_internal_notification(
|
||||
'VerifyStripeSubscriptionStatusJob failed for subscription ID '.$this->subscription->id.': '.$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
372
app/Livewire/GlobalSearch.php
Normal file
372
app/Livewire/GlobalSearch.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
|
||||
class GlobalSearch extends Component
|
||||
{
|
||||
public $searchQuery = '';
|
||||
|
||||
public $isModalOpen = false;
|
||||
|
||||
public $searchResults = [];
|
||||
|
||||
public $allSearchableItems = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->searchQuery = '';
|
||||
$this->isModalOpen = false;
|
||||
$this->searchResults = [];
|
||||
$this->allSearchableItems = [];
|
||||
}
|
||||
|
||||
public function openSearchModal()
|
||||
{
|
||||
$this->isModalOpen = true;
|
||||
$this->loadSearchableItems();
|
||||
$this->dispatch('search-modal-opened');
|
||||
}
|
||||
|
||||
public function closeSearchModal()
|
||||
{
|
||||
$this->isModalOpen = false;
|
||||
$this->searchQuery = '';
|
||||
$this->searchResults = [];
|
||||
}
|
||||
|
||||
public static function getCacheKey($teamId)
|
||||
{
|
||||
return 'global_search_items_'.$teamId;
|
||||
}
|
||||
|
||||
public static function clearTeamCache($teamId)
|
||||
{
|
||||
Cache::forget(self::getCacheKey($teamId));
|
||||
}
|
||||
|
||||
public function updatedSearchQuery()
|
||||
{
|
||||
$this->search();
|
||||
}
|
||||
|
||||
private function loadSearchableItems()
|
||||
{
|
||||
// Try to get from Redis cache first
|
||||
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
|
||||
|
||||
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
|
||||
ray()->showQueries();
|
||||
$items = collect();
|
||||
$team = auth()->user()->currentTeam();
|
||||
|
||||
// Get all applications
|
||||
$applications = Application::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($app) {
|
||||
// Collect all FQDNs from the application
|
||||
$fqdns = collect([]);
|
||||
|
||||
// For regular applications
|
||||
if ($app->fqdn) {
|
||||
$fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
|
||||
}
|
||||
|
||||
// For docker compose based applications
|
||||
if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) {
|
||||
try {
|
||||
$composeDomains = json_decode($app->docker_compose_domains, true);
|
||||
if (is_array($composeDomains)) {
|
||||
foreach ($composeDomains as $serviceName => $domains) {
|
||||
if (is_array($domains)) {
|
||||
$fqdns = $fqdns->merge($domains);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ignore JSON parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
$fqdnsString = $fqdns->implode(' ');
|
||||
|
||||
return [
|
||||
'id' => $app->id,
|
||||
'name' => $app->name,
|
||||
'type' => 'application',
|
||||
'uuid' => $app->uuid,
|
||||
'description' => $app->description,
|
||||
'link' => $app->link(),
|
||||
'project' => $app->environment->project->name ?? null,
|
||||
'environment' => $app->environment->name ?? null,
|
||||
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
|
||||
'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString),
|
||||
];
|
||||
});
|
||||
|
||||
// Get all services
|
||||
$services = Service::ownedByCurrentTeam()
|
||||
->with(['environment.project', 'applications'])
|
||||
->get()
|
||||
->map(function ($service) {
|
||||
// Collect all FQDNs from service applications
|
||||
$fqdns = collect([]);
|
||||
foreach ($service->applications as $app) {
|
||||
if ($app->fqdn) {
|
||||
$appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn));
|
||||
$fqdns = $fqdns->merge($appFqdns);
|
||||
}
|
||||
}
|
||||
$fqdnsString = $fqdns->implode(' ');
|
||||
|
||||
return [
|
||||
'id' => $service->id,
|
||||
'name' => $service->name,
|
||||
'type' => 'service',
|
||||
'uuid' => $service->uuid,
|
||||
'description' => $service->description,
|
||||
'link' => $service->link(),
|
||||
'project' => $service->environment->project->name ?? null,
|
||||
'environment' => $service->environment->name ?? null,
|
||||
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
|
||||
'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString),
|
||||
];
|
||||
});
|
||||
|
||||
// Get all standalone databases
|
||||
$databases = collect();
|
||||
|
||||
// PostgreSQL
|
||||
$databases = $databases->merge(
|
||||
StandalonePostgresql::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'postgresql',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' postgresql '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// MySQL
|
||||
$databases = $databases->merge(
|
||||
StandaloneMysql::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'mysql',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' mysql '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// MariaDB
|
||||
$databases = $databases->merge(
|
||||
StandaloneMariadb::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'mariadb',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' mariadb '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// MongoDB
|
||||
$databases = $databases->merge(
|
||||
StandaloneMongodb::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'mongodb',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' mongodb '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// Redis
|
||||
$databases = $databases->merge(
|
||||
StandaloneRedis::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'redis',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' redis '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// KeyDB
|
||||
$databases = $databases->merge(
|
||||
StandaloneKeydb::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'keydb',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' keydb '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// Dragonfly
|
||||
$databases = $databases->merge(
|
||||
StandaloneDragonfly::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'dragonfly',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' dragonfly '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// Clickhouse
|
||||
$databases = $databases->merge(
|
||||
StandaloneClickhouse::ownedByCurrentTeam()
|
||||
->with(['environment.project'])
|
||||
->get()
|
||||
->map(function ($db) {
|
||||
return [
|
||||
'id' => $db->id,
|
||||
'name' => $db->name,
|
||||
'type' => 'database',
|
||||
'subtype' => 'clickhouse',
|
||||
'uuid' => $db->uuid,
|
||||
'description' => $db->description,
|
||||
'link' => $db->link(),
|
||||
'project' => $db->environment->project->name ?? null,
|
||||
'environment' => $db->environment->name ?? null,
|
||||
'search_text' => strtolower($db->name.' clickhouse '.$db->description),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
// Get all servers
|
||||
$servers = Server::ownedByCurrentTeam()
|
||||
->get()
|
||||
->map(function ($server) {
|
||||
return [
|
||||
'id' => $server->id,
|
||||
'name' => $server->name,
|
||||
'type' => 'server',
|
||||
'uuid' => $server->uuid,
|
||||
'description' => $server->description,
|
||||
'link' => $server->url(),
|
||||
'project' => null,
|
||||
'environment' => null,
|
||||
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description),
|
||||
];
|
||||
});
|
||||
|
||||
// Merge all collections
|
||||
$items = $items->merge($applications)
|
||||
->merge($services)
|
||||
->merge($databases)
|
||||
->merge($servers);
|
||||
|
||||
return $items->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
private function search()
|
||||
{
|
||||
if (strlen($this->searchQuery) < 2) {
|
||||
$this->searchResults = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query = strtolower($this->searchQuery);
|
||||
|
||||
// Case-insensitive search in the items
|
||||
$this->searchResults = collect($this->allSearchableItems)
|
||||
->filter(function ($item) use ($query) {
|
||||
return str_contains($item['search_text'], $query);
|
||||
})
|
||||
->take(20)
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.global-search');
|
||||
}
|
||||
}
|
@@ -52,15 +52,24 @@ class DeploymentNavbar extends Component
|
||||
|
||||
public function cancel()
|
||||
{
|
||||
$kill_command = "docker rm -f {$this->application_deployment_queue->deployment_uuid}";
|
||||
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;
|
||||
$kill_command = "docker rm -f {$deployment_uuid}";
|
||||
$build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id;
|
||||
$server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id;
|
||||
|
||||
// First, mark the deployment as cancelled to prevent further processing
|
||||
$this->application_deployment_queue->update([
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
try {
|
||||
if ($this->application->settings->is_build_server_enabled) {
|
||||
$server = Server::ownedByCurrentTeam()->find($build_server_id);
|
||||
} else {
|
||||
$server = Server::ownedByCurrentTeam()->find($server_id);
|
||||
}
|
||||
|
||||
// Add cancellation log entry
|
||||
if ($this->application_deployment_queue->logs) {
|
||||
$previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
@@ -77,13 +86,35 @@ class DeploymentNavbar extends Component
|
||||
'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
}
|
||||
instant_remote_process([$kill_command], $server);
|
||||
|
||||
// Try to stop the helper container if it exists
|
||||
// Check if container exists first
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
// Container exists, kill it
|
||||
instant_remote_process([$kill_command], $server);
|
||||
} else {
|
||||
// Container hasn't started yet
|
||||
$this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
|
||||
}
|
||||
|
||||
// Also try to kill any running process if we have a process ID
|
||||
if ($this->application_deployment_queue->current_process_id) {
|
||||
try {
|
||||
$processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}";
|
||||
instant_remote_process([$processKillCommand], $server);
|
||||
} catch (\Throwable $e) {
|
||||
// Process might already be gone, that's ok
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Still mark as cancelled even if cleanup fails
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->application_deployment_queue->update([
|
||||
'current_process_id' => null,
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
next_after_cancel($server);
|
||||
}
|
||||
|
@@ -547,9 +547,10 @@ class General extends Component
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
|
||||
$domain = trim($domain);
|
||||
Url::fromString($domain, ['http', 'https']);
|
||||
|
||||
return str($domain)->trim()->lower();
|
||||
return str($domain)->lower();
|
||||
});
|
||||
|
||||
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
|
||||
|
@@ -127,7 +127,7 @@ class CloneMe extends Component
|
||||
$databases = $this->environment->databases();
|
||||
$services = $this->environment->services;
|
||||
foreach ($applications as $application) {
|
||||
$selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations)->where('id', $this->selectedDestination)->first();
|
||||
$selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first();
|
||||
clone_application($application, $selectedDestination, [
|
||||
'environment_id' => $environment->id,
|
||||
], $this->cloneVolumeData);
|
||||
|
@@ -143,7 +143,13 @@ class GithubPrivateRepository extends Component
|
||||
|
||||
protected function loadBranchByPage()
|
||||
{
|
||||
$response = Http::withToken($this->token)->get("{$this->github_app->api_url}/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches?per_page=100&page={$this->page}");
|
||||
$response = Http::GitHub($this->github_app->api_url, $this->token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [
|
||||
'per_page' => 100,
|
||||
'page' => $this->page,
|
||||
]);
|
||||
$json = $response->json();
|
||||
if ($response->status() !== 200) {
|
||||
return $this->dispatch('error', $json['message']);
|
||||
|
@@ -41,9 +41,10 @@ class EditDomain extends Component
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
|
||||
$domain = trim($domain);
|
||||
Url::fromString($domain, ['http', 'https']);
|
||||
|
||||
return str($domain)->trim()->lower();
|
||||
return str($domain)->lower();
|
||||
});
|
||||
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
|
||||
$warning = sslipDomainWarning($this->application->fqdn);
|
||||
|
@@ -149,9 +149,10 @@ class ServiceApplicationView extends Component
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
|
||||
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
|
||||
$domain = trim($domain);
|
||||
Url::fromString($domain, ['http', 'https']);
|
||||
|
||||
return str($domain)->trim()->lower();
|
||||
return str($domain)->lower();
|
||||
});
|
||||
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
|
||||
$warning = sslipDomainWarning($this->application->fqdn);
|
||||
|
@@ -20,7 +20,15 @@ class ConfigurationChecker extends Component
|
||||
|
||||
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
|
||||
|
||||
protected $listeners = ['configurationChanged'];
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged',
|
||||
'configurationChanged' => 'configurationChanged',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
|
@@ -2,12 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer;
|
||||
|
||||
public $parameters;
|
||||
|
||||
@@ -23,7 +24,11 @@ class Add extends Component
|
||||
|
||||
public bool $is_literal = false;
|
||||
|
||||
public bool $is_buildtime_only = false;
|
||||
public bool $is_runtime = true;
|
||||
|
||||
public bool $is_buildtime = true;
|
||||
|
||||
public array $problematicVariables = [];
|
||||
|
||||
protected $listeners = ['clearAddEnv' => 'clear'];
|
||||
|
||||
@@ -32,7 +37,8 @@ class Add extends Component
|
||||
'value' => 'nullable',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_buildtime_only' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
@@ -40,12 +46,14 @@ class Add extends Component
|
||||
'value' => 'value',
|
||||
'is_multiline' => 'multiline',
|
||||
'is_literal' => 'literal',
|
||||
'is_buildtime_only' => 'buildtime only',
|
||||
'is_runtime' => 'runtime',
|
||||
'is_buildtime' => 'buildtime',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
||||
}
|
||||
|
||||
public function submit()
|
||||
@@ -56,7 +64,8 @@ class Add extends Component
|
||||
'value' => $this->value,
|
||||
'is_multiline' => $this->is_multiline,
|
||||
'is_literal' => $this->is_literal,
|
||||
'is_buildtime_only' => $this->is_buildtime_only,
|
||||
'is_runtime' => $this->is_runtime,
|
||||
'is_buildtime' => $this->is_buildtime,
|
||||
'is_preview' => $this->is_preview,
|
||||
]);
|
||||
$this->clear();
|
||||
@@ -68,6 +77,7 @@ class Add extends Component
|
||||
$this->value = '';
|
||||
$this->is_multiline = false;
|
||||
$this->is_literal = false;
|
||||
$this->is_buildtime_only = false;
|
||||
$this->is_runtime = true;
|
||||
$this->is_buildtime = true;
|
||||
}
|
||||
}
|
||||
|
@@ -25,6 +25,8 @@ class All extends Component
|
||||
|
||||
public bool $is_env_sorting_enabled = false;
|
||||
|
||||
public bool $use_build_secrets = false;
|
||||
|
||||
protected $listeners = [
|
||||
'saveKey' => 'submit',
|
||||
'refreshEnvs',
|
||||
@@ -34,6 +36,7 @@ class All extends Component
|
||||
public function mount()
|
||||
{
|
||||
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
|
||||
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
|
||||
$this->resourceClass = get_class($this->resource);
|
||||
$resourceWithPreviews = [\App\Models\Application::class];
|
||||
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
|
||||
@@ -49,6 +52,7 @@ class All extends Component
|
||||
$this->authorize('manageEnvironment', $this->resource);
|
||||
|
||||
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
|
||||
$this->resource->settings->use_build_secrets = $this->use_build_secrets;
|
||||
$this->resource->settings->save();
|
||||
$this->getDevView();
|
||||
$this->dispatch('success', 'Environment variable settings updated.');
|
||||
@@ -217,7 +221,8 @@ class All extends Component
|
||||
$environment->value = $data['value'];
|
||||
$environment->is_multiline = $data['is_multiline'] ?? false;
|
||||
$environment->is_literal = $data['is_literal'] ?? false;
|
||||
$environment->is_buildtime_only = $data['is_buildtime_only'] ?? false;
|
||||
$environment->is_runtime = $data['is_runtime'] ?? true;
|
||||
$environment->is_buildtime = $data['is_buildtime'] ?? true;
|
||||
$environment->is_preview = $data['is_preview'] ?? false;
|
||||
$environment->resourceable_id = $this->resource->id;
|
||||
$environment->resourceable_type = $this->resource->getMorphClass();
|
||||
|
@@ -4,13 +4,14 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests, EnvironmentVariableProtection;
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
|
||||
|
||||
public $parameters;
|
||||
|
||||
@@ -38,7 +39,9 @@ class Show extends Component
|
||||
|
||||
public bool $is_shown_once = false;
|
||||
|
||||
public bool $is_buildtime_only = false;
|
||||
public bool $is_runtime = true;
|
||||
|
||||
public bool $is_buildtime = true;
|
||||
|
||||
public bool $is_required = false;
|
||||
|
||||
@@ -46,6 +49,8 @@ class Show extends Component
|
||||
|
||||
public bool $is_redis_credential = false;
|
||||
|
||||
public array $problematicVariables = [];
|
||||
|
||||
protected $listeners = [
|
||||
'refreshEnvs' => 'refresh',
|
||||
'refresh',
|
||||
@@ -58,7 +63,8 @@ class Show extends Component
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_shown_once' => 'required|boolean',
|
||||
'is_buildtime_only' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
'real_value' => 'nullable',
|
||||
'is_required' => 'required|boolean',
|
||||
];
|
||||
@@ -74,6 +80,7 @@ class Show extends Component
|
||||
if ($this->type === 'standalone-redis' && ($this->env->key === 'REDIS_PASSWORD' || $this->env->key === 'REDIS_USERNAME')) {
|
||||
$this->is_redis_credential = true;
|
||||
}
|
||||
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
||||
}
|
||||
|
||||
public function getResourceProperty()
|
||||
@@ -102,7 +109,8 @@ class Show extends Component
|
||||
} else {
|
||||
$this->validate();
|
||||
$this->env->is_required = $this->is_required;
|
||||
$this->env->is_buildtime_only = $this->is_buildtime_only;
|
||||
$this->env->is_runtime = $this->is_runtime;
|
||||
$this->env->is_buildtime = $this->is_buildtime;
|
||||
$this->env->is_shared = $this->is_shared;
|
||||
}
|
||||
$this->env->key = $this->key;
|
||||
@@ -117,7 +125,8 @@ class Show extends Component
|
||||
$this->is_multiline = $this->env->is_multiline;
|
||||
$this->is_literal = $this->env->is_literal;
|
||||
$this->is_shown_once = $this->env->is_shown_once;
|
||||
$this->is_buildtime_only = $this->env->is_buildtime_only ?? false;
|
||||
$this->is_runtime = $this->env->is_runtime ?? true;
|
||||
$this->is_buildtime = $this->env->is_buildtime ?? true;
|
||||
$this->is_required = $this->env->is_required ?? false;
|
||||
$this->is_really_required = $this->env->is_really_required ?? false;
|
||||
$this->is_shared = $this->env->is_shared ?? false;
|
||||
|
@@ -8,7 +8,7 @@ class Metrics extends Component
|
||||
{
|
||||
public $resource;
|
||||
|
||||
public $chartId = 'container-cpu';
|
||||
public $chartId = 'metrics';
|
||||
|
||||
public $data;
|
||||
|
||||
|
@@ -32,7 +32,7 @@ class Navbar extends Component
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
'refreshServerShow' => '$refresh',
|
||||
'refreshServerShow' => 'refreshServer',
|
||||
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
|
||||
];
|
||||
}
|
||||
@@ -134,6 +134,12 @@ class Navbar extends Component
|
||||
|
||||
}
|
||||
|
||||
public function refreshServer()
|
||||
{
|
||||
$this->server->refresh();
|
||||
$this->server->load('settings');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.navbar');
|
||||
|
@@ -5,6 +5,7 @@ namespace App\Livewire\Server\PrivateKey;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
@@ -35,19 +36,20 @@ class Show extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$originalPrivateKeyId = $this->server->getOriginal('private_key_id');
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->server->update(['private_key_id' => $privateKeyId]);
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true);
|
||||
if ($uptime) {
|
||||
$this->dispatch('success', 'Private key updated successfully.');
|
||||
} else {
|
||||
throw new \Exception($error);
|
||||
}
|
||||
DB::transaction(function () use ($ownedPrivateKey) {
|
||||
$this->server->privateKey()->associate($ownedPrivateKey);
|
||||
$this->server->save();
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true);
|
||||
if (! $uptime) {
|
||||
throw new \Exception($error);
|
||||
}
|
||||
});
|
||||
$this->dispatch('success', 'Private key updated successfully.');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Exception $e) {
|
||||
$this->server->update(['private_key_id' => $originalPrivateKeyId]);
|
||||
$this->server->refresh();
|
||||
$this->server->validateConnection();
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
}
|
||||
@@ -59,6 +61,7 @@ class Show extends Component
|
||||
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if ($uptime) {
|
||||
$this->dispatch('success', 'Server is reachable.');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} else {
|
||||
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);
|
||||
|
||||
|
@@ -45,7 +45,7 @@ class Proxy extends Component
|
||||
|
||||
public function getConfigurationFilePathProperty()
|
||||
{
|
||||
return $this->server->proxyPath().'/docker-compose.yml';
|
||||
return $this->server->proxyPath().'docker-compose.yml';
|
||||
}
|
||||
|
||||
public function changeProxy()
|
||||
|
@@ -271,7 +271,7 @@ class Show extends Component
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
$this->server->restartSentinel($customImage);
|
||||
$this->dispatch('success', 'Restarting Sentinel.');
|
||||
$this->dispatch('info', 'Restarting Sentinel.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -298,11 +298,36 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedIsBuildServer($value)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if ($value === true && $this->isSentinelEnabled) {
|
||||
$this->isSentinelEnabled = false;
|
||||
$this->isMetricsEnabled = false;
|
||||
$this->isSentinelDebugEnabled = false;
|
||||
StopSentinel::dispatch($this->server);
|
||||
$this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.');
|
||||
}
|
||||
$this->submit();
|
||||
// Dispatch event to refresh the navbar
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedIsSentinelEnabled($value)
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
if ($value === true) {
|
||||
if ($this->isBuildServer) {
|
||||
$this->isSentinelEnabled = false;
|
||||
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
|
||||
|
||||
return;
|
||||
}
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
StartSentinel::run($this->server, true, null, $customImage);
|
||||
} else {
|
||||
@@ -330,7 +355,7 @@ class Show extends Component
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->submit();
|
||||
$this->syncData(true);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -340,7 +365,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Server updated.');
|
||||
$this->dispatch('success', 'Server settings updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Services\ConfigurationGenerator;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -110,7 +111,7 @@ use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Application extends BaseModel
|
||||
{
|
||||
use HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
@@ -123,66 +124,6 @@ class Application extends BaseModel
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
];
|
||||
|
||||
public function customNetworkAliases(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: function ($value) {
|
||||
if (is_null($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If it's already a JSON string, decode it
|
||||
if (is_string($value) && $this->isJson($value)) {
|
||||
$value = json_decode($value, true);
|
||||
}
|
||||
|
||||
// If it's a string but not JSON, treat it as a comma-separated list
|
||||
if (is_string($value) && ! is_array($value)) {
|
||||
$value = explode(',', $value);
|
||||
}
|
||||
|
||||
$value = collect($value)
|
||||
->map(function ($alias) {
|
||||
if (is_string($alias)) {
|
||||
return str_replace(' ', '-', trim($alias));
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->filter()
|
||||
->unique() // Remove duplicate values
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return empty($value) ? null : json_encode($value);
|
||||
},
|
||||
get: function ($value) {
|
||||
if (is_null($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value) && $this->isJson($value)) {
|
||||
return json_decode($value, true);
|
||||
}
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid JSON
|
||||
*/
|
||||
private function isJson($string)
|
||||
{
|
||||
if (! is_string($string)) {
|
||||
return false;
|
||||
}
|
||||
json_decode($string);
|
||||
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::addGlobalScope('withRelations', function ($builder) {
|
||||
@@ -250,6 +191,66 @@ class Application extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public function customNetworkAliases(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: function ($value) {
|
||||
if (is_null($value) || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If it's already a JSON string, decode it
|
||||
if (is_string($value) && $this->isJson($value)) {
|
||||
$value = json_decode($value, true);
|
||||
}
|
||||
|
||||
// If it's a string but not JSON, treat it as a comma-separated list
|
||||
if (is_string($value) && ! is_array($value)) {
|
||||
$value = explode(',', $value);
|
||||
}
|
||||
|
||||
$value = collect($value)
|
||||
->map(function ($alias) {
|
||||
if (is_string($alias)) {
|
||||
return str_replace(' ', '-', trim($alias));
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->filter()
|
||||
->unique() // Remove duplicate values
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return empty($value) ? null : json_encode($value);
|
||||
},
|
||||
get: function ($value) {
|
||||
if (is_null($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_string($value) && $this->isJson($value)) {
|
||||
return json_decode($value, true);
|
||||
}
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid JSON
|
||||
*/
|
||||
private function isJson($string)
|
||||
{
|
||||
if (! is_string($string)) {
|
||||
return false;
|
||||
}
|
||||
json_decode($string);
|
||||
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name');
|
||||
@@ -932,11 +933,11 @@ class Application extends BaseModel
|
||||
|
||||
public function isConfigurationChanged(bool $save = false)
|
||||
{
|
||||
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels);
|
||||
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets);
|
||||
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal'])->sort());
|
||||
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
} else {
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal'])->sort());
|
||||
$newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
|
||||
}
|
||||
$newConfigHash = md5($newConfigHash);
|
||||
$oldConfigHash = data_get($this, 'config_hash');
|
||||
|
@@ -85,6 +85,47 @@ class ApplicationDeploymentQueue extends Model
|
||||
return str($this->commit_message)->value();
|
||||
}
|
||||
|
||||
private function redactSensitiveInfo($text)
|
||||
{
|
||||
$text = remove_iip($text);
|
||||
|
||||
$app = $this->application;
|
||||
if (! $app) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$lockedVars = collect([]);
|
||||
|
||||
if ($app->environment_variables) {
|
||||
$lockedVars = $lockedVars->merge(
|
||||
$app->environment_variables
|
||||
->where('is_shown_once', true)
|
||||
->pluck('real_value', 'key')
|
||||
->filter()
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->pull_request_id !== 0 && $app->environment_variables_preview) {
|
||||
$lockedVars = $lockedVars->merge(
|
||||
$app->environment_variables_preview
|
||||
->where('is_shown_once', true)
|
||||
->pluck('real_value', 'key')
|
||||
->filter()
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($lockedVars as $key => $value) {
|
||||
$escapedValue = preg_quote($value, '/');
|
||||
$text = preg_replace(
|
||||
'/'.$escapedValue.'/',
|
||||
REDACTED,
|
||||
$text
|
||||
);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
|
||||
{
|
||||
if ($type === 'error') {
|
||||
@@ -96,7 +137,7 @@ class ApplicationDeploymentQueue extends Model
|
||||
}
|
||||
$newLogEntry = [
|
||||
'command' => null,
|
||||
'output' => remove_iip($message),
|
||||
'output' => $this->redactSensitiveInfo($message),
|
||||
'type' => $type,
|
||||
'timestamp' => Carbon::now('UTC'),
|
||||
'hidden' => $hidden,
|
||||
|
@@ -17,7 +17,8 @@ use OpenApi\Attributes as OA;
|
||||
'is_literal' => ['type' => 'boolean'],
|
||||
'is_multiline' => ['type' => 'boolean'],
|
||||
'is_preview' => ['type' => 'boolean'],
|
||||
'is_buildtime_only' => ['type' => 'boolean'],
|
||||
'is_runtime' => ['type' => 'boolean'],
|
||||
'is_buildtime' => ['type' => 'boolean'],
|
||||
'is_shared' => ['type' => 'boolean'],
|
||||
'is_shown_once' => ['type' => 'boolean'],
|
||||
'key' => ['type' => 'string'],
|
||||
@@ -37,13 +38,14 @@ class EnvironmentVariable extends BaseModel
|
||||
'value' => 'encrypted',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_preview' => 'boolean',
|
||||
'is_buildtime_only' => 'boolean',
|
||||
'is_runtime' => 'boolean',
|
||||
'is_buildtime' => 'boolean',
|
||||
'version' => 'string',
|
||||
'resourceable_type' => 'string',
|
||||
'resourceable_id' => 'integer',
|
||||
];
|
||||
|
||||
protected $appends = ['real_value', 'is_shared', 'is_really_required'];
|
||||
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
@@ -137,6 +139,32 @@ class EnvironmentVariable extends BaseModel
|
||||
);
|
||||
}
|
||||
|
||||
protected function isNixpacks(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (str($this->key)->startsWith('NIXPACKS_')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function isCoolify(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (str($this->key)->startsWith('SERVICE_')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function isShared(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
@@ -10,6 +10,21 @@ class ScheduledDatabaseBackup extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return ScheduledDatabaseBackup::whereRelation('team', 'id', $teamId)->orderBy('name');
|
||||
}
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function database(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
|
@@ -13,6 +13,7 @@ use App\Jobs\RegenerateSslCertJob;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -55,7 +56,7 @@ use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Server extends BaseModel
|
||||
{
|
||||
use HasFactory, SchemalessAttributesTrait, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes;
|
||||
|
||||
public static $batch_counter = 0;
|
||||
|
||||
@@ -1082,7 +1083,6 @@ $schema://$host {
|
||||
|
||||
public function validateConnection(bool $justCheckingNewKey = false)
|
||||
{
|
||||
ray('validateConnection', $this->id);
|
||||
$this->disableSshMux();
|
||||
|
||||
if ($this->skipServer()) {
|
||||
|
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -41,7 +42,7 @@ use Visus\Cuid2\Cuid2;
|
||||
)]
|
||||
class Service extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneClickhouse extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -43,6 +44,11 @@ class StandaloneClickhouse extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneDragonfly extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -43,6 +44,11 @@ class StandaloneDragonfly extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneKeydb extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -43,6 +44,11 @@ class StandaloneKeydb extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMariadb extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -44,6 +45,11 @@ class StandaloneMariadb extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMongodb extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -46,6 +47,11 @@ class StandaloneMongodb extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMysql extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -44,6 +45,11 @@ class StandaloneMysql extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandalonePostgresql extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -44,6 +45,11 @@ class StandalonePostgresql extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
public function workdir()
|
||||
{
|
||||
return database_configuration_dir()."/{$this->uuid}";
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneRedis extends BaseModel
|
||||
{
|
||||
use HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
@@ -45,6 +46,11 @@ class StandaloneRedis extends BaseModel
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
|
||||
}
|
||||
|
||||
protected function serverStatus(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
@@ -193,6 +193,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
public function subscriptionEnded()
|
||||
{
|
||||
$this->subscription->update([
|
||||
'stripe_subscription_id' => null,
|
||||
'stripe_cancel_at_period_end' => false,
|
||||
'stripe_invoice_paid' => false,
|
||||
'stripe_trial_already_ended' => false,
|
||||
|
@@ -31,7 +31,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
$dangerousChars = [
|
||||
';', '|', '&', '$', '`', '(', ')', '{', '}',
|
||||
'[', ']', '<', '>', '\n', '\r', '\0', '"', "'",
|
||||
'\\', '!', '?', '*', '~', '^', '%', '=', '+',
|
||||
'\\', '!', '?', '*', '^', '%', '=', '+',
|
||||
'#', // Comment character that could hide commands
|
||||
];
|
||||
|
||||
@@ -85,7 +85,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
}
|
||||
|
||||
// Validate SSH URL format (git@host:user/repo.git)
|
||||
if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.]+$/', $value)) {
|
||||
if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.~]+$/', $value)) {
|
||||
$fail('The :attribute is not a valid SSH repository URL.');
|
||||
|
||||
return;
|
||||
|
86
app/Traits/ClearsGlobalSearchCache.php
Normal file
86
app/Traits/ClearsGlobalSearchCache.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Livewire\GlobalSearch;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
trait ClearsGlobalSearchCache
|
||||
{
|
||||
protected static function bootClearsGlobalSearchCache()
|
||||
{
|
||||
static::saving(function ($model) {
|
||||
// Only clear cache if searchable fields are being changed
|
||||
if ($model->hasSearchableChanges()) {
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::created(function ($model) {
|
||||
// Always clear cache when model is created
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function ($model) {
|
||||
// Always clear cache when model is deleted
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function hasSearchableChanges(): bool
|
||||
{
|
||||
// Define searchable fields based on model type
|
||||
$searchableFields = ['name', 'description'];
|
||||
|
||||
// Add model-specific searchable fields
|
||||
if ($this instanceof \App\Models\Application) {
|
||||
$searchableFields[] = 'fqdn';
|
||||
$searchableFields[] = 'docker_compose_domains';
|
||||
} elseif ($this instanceof \App\Models\Server) {
|
||||
$searchableFields[] = 'ip';
|
||||
} elseif ($this instanceof \App\Models\Service) {
|
||||
// Services don't have direct fqdn, but name and description are covered
|
||||
}
|
||||
// Database models only have name and description as searchable
|
||||
|
||||
// Check if any searchable field is dirty
|
||||
foreach ($searchableFields as $field) {
|
||||
if ($this->isDirty($field)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getTeamIdForCache()
|
||||
{
|
||||
// For database models, team is accessed through environment.project.team
|
||||
if (method_exists($this, 'team')) {
|
||||
if ($this instanceof \App\Models\Server) {
|
||||
$team = $this->team;
|
||||
} else {
|
||||
$team = $this->team();
|
||||
}
|
||||
if (filled($team)) {
|
||||
return is_object($team) ? $team->id : null;
|
||||
}
|
||||
}
|
||||
|
||||
// For models with direct team_id property
|
||||
if (property_exists($this, 'team_id') || isset($this->team_id)) {
|
||||
return $this->team_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
221
app/Traits/EnvironmentVariableAnalyzer.php
Normal file
221
app/Traits/EnvironmentVariableAnalyzer.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
trait EnvironmentVariableAnalyzer
|
||||
{
|
||||
/**
|
||||
* List of environment variables that commonly cause build issues when set to production values.
|
||||
* Each entry contains the variable pattern and associated metadata.
|
||||
*/
|
||||
protected static function getProblematicBuildVariables(): array
|
||||
{
|
||||
return [
|
||||
'NODE_ENV' => [
|
||||
'problematic_values' => ['production', 'prod'],
|
||||
'affects' => 'Node.js/npm/yarn/bun/pnpm',
|
||||
'issue' => 'Skips devDependencies installation which are often required for building (webpack, typescript, etc.)',
|
||||
'recommendation' => 'Uncheck "Available at Buildtime" or use "development" during build',
|
||||
],
|
||||
'NPM_CONFIG_PRODUCTION' => [
|
||||
'problematic_values' => ['true', '1', 'yes'],
|
||||
'affects' => 'npm/pnpm',
|
||||
'issue' => 'Forces npm to skip devDependencies',
|
||||
'recommendation' => 'Remove from build-time variables or set to false',
|
||||
],
|
||||
'YARN_PRODUCTION' => [
|
||||
'problematic_values' => ['true', '1', 'yes'],
|
||||
'affects' => 'Yarn/pnpm',
|
||||
'issue' => 'Forces yarn to skip devDependencies',
|
||||
'recommendation' => 'Remove from build-time variables or set to false',
|
||||
],
|
||||
'COMPOSER_NO_DEV' => [
|
||||
'problematic_values' => ['1', 'true', 'yes'],
|
||||
'affects' => 'PHP/Composer',
|
||||
'issue' => 'Skips require-dev packages which may include build tools',
|
||||
'recommendation' => 'Set as "Runtime only" or remove from build-time variables',
|
||||
],
|
||||
'MIX_ENV' => [
|
||||
'problematic_values' => ['prod', 'production'],
|
||||
'affects' => 'Elixir/Phoenix',
|
||||
'issue' => 'Production mode may skip development dependencies needed for compilation',
|
||||
'recommendation' => 'Use "dev" for build or set as "Runtime only"',
|
||||
],
|
||||
'RAILS_ENV' => [
|
||||
'problematic_values' => ['production'],
|
||||
'affects' => 'Ruby on Rails',
|
||||
'issue' => 'May affect asset precompilation and dependency handling',
|
||||
'recommendation' => 'Consider using "development" for build phase',
|
||||
],
|
||||
'RACK_ENV' => [
|
||||
'problematic_values' => ['production'],
|
||||
'affects' => 'Ruby/Rack',
|
||||
'issue' => 'May affect dependency handling and build behavior',
|
||||
'recommendation' => 'Consider using "development" for build phase',
|
||||
],
|
||||
'BUNDLE_WITHOUT' => [
|
||||
'problematic_values' => ['development', 'test', 'development:test'],
|
||||
'affects' => 'Ruby/Bundler',
|
||||
'issue' => 'Excludes gem groups that may contain build dependencies',
|
||||
'recommendation' => 'Remove from build-time variables or adjust groups',
|
||||
],
|
||||
'FLASK_ENV' => [
|
||||
'problematic_values' => ['production'],
|
||||
'affects' => 'Python/Flask',
|
||||
'issue' => 'May affect debug mode and development tools availability',
|
||||
'recommendation' => 'Usually safe, but consider "development" for complex builds',
|
||||
],
|
||||
'DJANGO_SETTINGS_MODULE' => [
|
||||
'problematic_values' => [], // Check if contains 'production' or 'prod'
|
||||
'affects' => 'Python/Django',
|
||||
'issue' => 'Production settings may disable debug tools needed during build',
|
||||
'recommendation' => 'Use development settings for build phase',
|
||||
'check_function' => 'checkDjangoSettings',
|
||||
],
|
||||
'APP_ENV' => [
|
||||
'problematic_values' => ['production', 'prod'],
|
||||
'affects' => 'Laravel/Symfony',
|
||||
'issue' => 'May affect dependency installation and build optimizations',
|
||||
'recommendation' => 'Consider using "local" or "development" for build',
|
||||
],
|
||||
'ASPNETCORE_ENVIRONMENT' => [
|
||||
'problematic_values' => ['Production'],
|
||||
'affects' => '.NET/ASP.NET Core',
|
||||
'issue' => 'May affect build-time configurations and optimizations',
|
||||
'recommendation' => 'Usually safe, but verify build requirements',
|
||||
],
|
||||
'CI' => [
|
||||
'problematic_values' => ['true', '1', 'yes'],
|
||||
'affects' => 'Various tools',
|
||||
'issue' => 'Changes behavior in many tools (disables interactivity, changes caching)',
|
||||
'recommendation' => 'Usually beneficial for builds, but be aware of behavior changes',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze an environment variable for potential build issues.
|
||||
* Always returns a warning if the key is in our list, regardless of value.
|
||||
*/
|
||||
public static function analyzeBuildVariable(string $key, string $value): ?array
|
||||
{
|
||||
$problematicVars = self::getProblematicBuildVariables();
|
||||
|
||||
// Direct key match
|
||||
if (isset($problematicVars[$key])) {
|
||||
$config = $problematicVars[$key];
|
||||
|
||||
// Check if it has a custom check function
|
||||
if (isset($config['check_function'])) {
|
||||
$method = $config['check_function'];
|
||||
if (method_exists(self::class, $method)) {
|
||||
return self::{$method}($key, $value, $config);
|
||||
}
|
||||
}
|
||||
|
||||
// Always return warning for known problematic variables
|
||||
return [
|
||||
'variable' => $key,
|
||||
'value' => $value,
|
||||
'affects' => $config['affects'],
|
||||
'issue' => $config['issue'],
|
||||
'recommendation' => $config['recommendation'],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze multiple environment variables for potential build issues.
|
||||
*/
|
||||
public static function analyzeBuildVariables(array $variables): array
|
||||
{
|
||||
$warnings = [];
|
||||
|
||||
foreach ($variables as $key => $value) {
|
||||
$warning = self::analyzeBuildVariable($key, $value);
|
||||
if ($warning) {
|
||||
$warnings[] = $warning;
|
||||
}
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom check for Django settings module.
|
||||
*/
|
||||
protected static function checkDjangoSettings(string $key, string $value, array $config): ?array
|
||||
{
|
||||
// Always return warning for DJANGO_SETTINGS_MODULE when it's set as build-time
|
||||
return [
|
||||
'variable' => $key,
|
||||
'value' => $value,
|
||||
'affects' => $config['affects'],
|
||||
'issue' => $config['issue'],
|
||||
'recommendation' => $config['recommendation'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a formatted warning message for deployment logs.
|
||||
*/
|
||||
public static function formatBuildWarning(array $warning): array
|
||||
{
|
||||
$messages = [
|
||||
"⚠️ Build-time environment variable warning: {$warning['variable']}={$warning['value']}",
|
||||
" Affects: {$warning['affects']}",
|
||||
" Issue: {$warning['issue']}",
|
||||
" Recommendation: {$warning['recommendation']}",
|
||||
];
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a variable should show a warning in the UI.
|
||||
*/
|
||||
public static function shouldShowBuildWarning(string $key): bool
|
||||
{
|
||||
return isset(self::getProblematicBuildVariables()[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UI warning message for a specific variable.
|
||||
*/
|
||||
public static function getUIWarningMessage(string $key): ?string
|
||||
{
|
||||
$problematicVars = self::getProblematicBuildVariables();
|
||||
|
||||
if (! isset($problematicVars[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$config = $problematicVars[$key];
|
||||
$problematicValuesStr = implode(', ', $config['problematic_values']);
|
||||
|
||||
return "Setting {$key} to {$problematicValuesStr} as a build-time variable may cause issues. {$config['issue']} Consider: {$config['recommendation']}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get problematic variables configuration for frontend use.
|
||||
*/
|
||||
public static function getProblematicVariablesForFrontend(): array
|
||||
{
|
||||
$vars = self::getProblematicBuildVariables();
|
||||
$result = [];
|
||||
|
||||
foreach ($vars as $key => $config) {
|
||||
// Skip the check_function as it's PHP-specific
|
||||
$result[$key] = [
|
||||
'problematic_values' => $config['problematic_values'],
|
||||
'affects' => $config['affects'],
|
||||
'issue' => $config['issue'],
|
||||
'recommendation' => $config['recommendation'],
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
@@ -17,6 +17,46 @@ trait ExecuteRemoteCommand
|
||||
|
||||
public static int $batch_counter = 0;
|
||||
|
||||
private function redact_sensitive_info($text)
|
||||
{
|
||||
$text = remove_iip($text);
|
||||
|
||||
if (! isset($this->application)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
$lockedVars = collect([]);
|
||||
|
||||
if (isset($this->application->environment_variables)) {
|
||||
$lockedVars = $lockedVars->merge(
|
||||
$this->application->environment_variables
|
||||
->where('is_shown_once', true)
|
||||
->pluck('real_value', 'key')
|
||||
->filter()
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) {
|
||||
$lockedVars = $lockedVars->merge(
|
||||
$this->application->environment_variables_preview
|
||||
->where('is_shown_once', true)
|
||||
->pluck('real_value', 'key')
|
||||
->filter()
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($lockedVars as $key => $value) {
|
||||
$escapedValue = preg_quote($value, '/');
|
||||
$text = preg_replace(
|
||||
'/'.$escapedValue.'/',
|
||||
REDACTED,
|
||||
$text
|
||||
);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function execute_remote_command(...$commands)
|
||||
{
|
||||
static::$batch_counter++;
|
||||
@@ -46,6 +86,14 @@ trait ExecuteRemoteCommand
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cancellation before executing commands
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
$maxRetries = config('constants.ssh.max_retries');
|
||||
$attempt = 0;
|
||||
$lastError = null;
|
||||
@@ -66,13 +114,19 @@ trait ExecuteRemoteCommand
|
||||
// Track SSH retry event in Sentry
|
||||
$this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [
|
||||
'server' => $this->server->name ?? $this->server->ip ?? 'unknown',
|
||||
'command' => remove_iip($command),
|
||||
'command' => $this->redact_sensitive_info($command),
|
||||
'trait' => 'ExecuteRemoteCommand',
|
||||
]);
|
||||
|
||||
// Add log entry for the retry
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage);
|
||||
|
||||
// Check for cancellation during retry wait
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user during retry', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
sleep($delay);
|
||||
@@ -85,6 +139,11 @@ trait ExecuteRemoteCommand
|
||||
|
||||
// If we exhausted all retries and still failed
|
||||
if (! $commandExecuted && $lastError) {
|
||||
// Now we can set the status to FAILED since all retries have been exhausted
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
|
||||
$this->application_deployment_queue->save();
|
||||
}
|
||||
throw $lastError;
|
||||
}
|
||||
});
|
||||
@@ -106,8 +165,8 @@ trait ExecuteRemoteCommand
|
||||
$sanitized_output = sanitize_utf8_text($output);
|
||||
|
||||
$new_log_entry = [
|
||||
'command' => remove_iip($command),
|
||||
'output' => remove_iip($sanitized_output),
|
||||
'command' => $this->redact_sensitive_info($command),
|
||||
'output' => $this->redact_sensitive_info($sanitized_output),
|
||||
'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
|
||||
'timestamp' => Carbon::now('UTC'),
|
||||
'hidden' => $hidden,
|
||||
@@ -160,8 +219,8 @@ trait ExecuteRemoteCommand
|
||||
$process_result = $process->wait();
|
||||
if ($process_result->exitCode() !== 0) {
|
||||
if (! $ignore_errors) {
|
||||
$this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
|
||||
$this->application_deployment_queue->save();
|
||||
// Don't immediately set to FAILED - let the retry logic handle it
|
||||
// This prevents premature status changes during retryable SSH errors
|
||||
throw new \RuntimeException($process_result->errorOutput());
|
||||
}
|
||||
}
|
||||
@@ -175,7 +234,7 @@ trait ExecuteRemoteCommand
|
||||
$retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}";
|
||||
|
||||
$new_log_entry = [
|
||||
'output' => remove_iip($retryMessage),
|
||||
'output' => $this->redact_sensitive_info($retryMessage),
|
||||
'type' => 'stdout',
|
||||
'timestamp' => Carbon::now('UTC'),
|
||||
'hidden' => false,
|
||||
|
@@ -1093,11 +1093,11 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
|
||||
{
|
||||
if ($server->isSwarm()) {
|
||||
$output = instant_remote_process([
|
||||
"docker service logs -n {$lines} {$container_id}",
|
||||
"docker service logs -n {$lines} {$container_id} 2>&1",
|
||||
], $server);
|
||||
} else {
|
||||
$output = instant_remote_process([
|
||||
"docker logs -n {$lines} {$container_id}",
|
||||
"docker logs -n {$lines} {$container_id} 2>&1",
|
||||
], $server);
|
||||
}
|
||||
|
||||
@@ -1105,7 +1105,6 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
function escapeEnvVariables($value)
|
||||
{
|
||||
$search = ['\\', "\r", "\t", "\x0", '"', "'"];
|
||||
|
@@ -135,7 +135,13 @@ function getPermissionsPath(GithubApp $source)
|
||||
|
||||
function loadRepositoryByPage(GithubApp $source, string $token, int $page)
|
||||
{
|
||||
$response = Http::withToken($token)->get("{$source->api_url}/installation/repositories?per_page=100&page={$page}");
|
||||
$response = Http::GitHub($source->api_url, $token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get('/installation/repositories', [
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
]);
|
||||
$json = $response->json();
|
||||
if ($response->status() !== 200) {
|
||||
return [
|
||||
|
@@ -84,64 +84,6 @@ function instant_scp(string $source, string $dest, Server $server, $throwError =
|
||||
);
|
||||
}
|
||||
|
||||
function transfer_file_to_container(string $content, string $container_path, string $deployment_uuid, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
|
||||
|
||||
try {
|
||||
// Write content to temporary file
|
||||
file_put_contents($temp_file, $content);
|
||||
|
||||
// Generate unique filename for server transfer
|
||||
$server_temp_file = '/tmp/coolify_env_'.uniqid().'_'.$deployment_uuid;
|
||||
|
||||
// Transfer file to server
|
||||
instant_scp($temp_file, $server_temp_file, $server, $throwError);
|
||||
|
||||
// Ensure parent directory exists in container, then copy file
|
||||
$parent_dir = dirname($container_path);
|
||||
$commands = [];
|
||||
if ($parent_dir !== '.' && $parent_dir !== '/') {
|
||||
$commands[] = executeInDocker($deployment_uuid, "mkdir -p \"$parent_dir\"");
|
||||
}
|
||||
$commands[] = "docker cp $server_temp_file $deployment_uuid:$container_path";
|
||||
$commands[] = "rm -f $server_temp_file"; // Cleanup server temp file
|
||||
|
||||
return instant_remote_process_with_timeout($commands, $server, $throwError);
|
||||
|
||||
} finally {
|
||||
// Always cleanup local temp file
|
||||
if (file_exists($temp_file)) {
|
||||
unlink($temp_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function transfer_file_to_server(string $content, string $server_path, Server $server, bool $throwError = true): ?string
|
||||
{
|
||||
$temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
|
||||
|
||||
try {
|
||||
// Write content to temporary file
|
||||
file_put_contents($temp_file, $content);
|
||||
|
||||
// Ensure parent directory exists on server
|
||||
$parent_dir = dirname($server_path);
|
||||
if ($parent_dir !== '.' && $parent_dir !== '/') {
|
||||
instant_remote_process_with_timeout(["mkdir -p \"$parent_dir\""], $server, $throwError);
|
||||
}
|
||||
|
||||
// Transfer file directly to server destination
|
||||
return instant_scp($temp_file, $server_path, $server, $throwError);
|
||||
|
||||
} finally {
|
||||
// Always cleanup local temp file
|
||||
if (file_exists($temp_file)) {
|
||||
unlink($temp_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
|
||||
{
|
||||
$command = $command instanceof Collection ? $command->toArray() : $command;
|
||||
|
@@ -634,10 +634,14 @@ function getTopLevelNetworks(Service|Application $resource)
|
||||
$definedNetwork = collect([$resource->uuid]);
|
||||
$services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) {
|
||||
$serviceNetworks = collect(data_get($service, 'networks', []));
|
||||
$hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false;
|
||||
$networkMode = data_get($service, 'network_mode');
|
||||
|
||||
// Only add 'networks' key if 'network_mode' is not 'host'
|
||||
if (! $hasHostNetworkMode) {
|
||||
$hasValidNetworkMode =
|
||||
$networkMode === 'host' ||
|
||||
(is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:')));
|
||||
|
||||
// Only add 'networks' key if 'network_mode' is not 'host' or does not start with 'service:' or 'container:'
|
||||
if (! $hasValidNetworkMode) {
|
||||
// Collect/create/update networks
|
||||
if ($serviceNetworks->count() > 0) {
|
||||
foreach ($serviceNetworks as $networkName => $networkDetails) {
|
||||
@@ -1272,7 +1276,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
$serviceNetworks = collect(data_get($service, 'networks', []));
|
||||
$serviceVariables = collect(data_get($service, 'environment', []));
|
||||
$serviceLabels = collect(data_get($service, 'labels', []));
|
||||
$hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false;
|
||||
$networkMode = data_get($service, 'network_mode');
|
||||
|
||||
$hasValidNetworkMode =
|
||||
$networkMode === 'host' ||
|
||||
(is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:')));
|
||||
|
||||
if ($serviceLabels->count() > 0) {
|
||||
$removedLabels = collect([]);
|
||||
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
|
||||
@@ -1383,7 +1392,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||
$savedService->ports = $collectedPorts->implode(',');
|
||||
$savedService->save();
|
||||
|
||||
if (! $hasHostNetworkMode) {
|
||||
if (! $hasValidNetworkMode) {
|
||||
// Add Coolify specific networks
|
||||
$definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) {
|
||||
return $value == $definedNetwork;
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.428',
|
||||
'version' => '4.0.0-beta.429',
|
||||
'helper_version' => '1.0.11',
|
||||
'realtime_version' => '1.0.10',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('application_settings', function (Blueprint $table) {
|
||||
$table->boolean('use_build_secrets')->default(false)->after('is_build_server_enabled');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('application_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('use_build_secrets');
|
||||
});
|
||||
}
|
||||
};
|
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
// Add new boolean fields with defaults
|
||||
$table->boolean('is_runtime')->default(true)->after('is_buildtime_only');
|
||||
$table->boolean('is_buildtime')->default(true)->after('is_runtime');
|
||||
});
|
||||
|
||||
// Migrate existing data from is_buildtime_only to new fields
|
||||
DB::table('environment_variables')
|
||||
->where('is_buildtime_only', true)
|
||||
->update([
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
DB::table('environment_variables')
|
||||
->where('is_buildtime_only', false)
|
||||
->update([
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
// Remove the old is_buildtime_only column
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
$table->dropColumn('is_buildtime_only');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
// Re-add the is_buildtime_only column
|
||||
$table->boolean('is_buildtime_only')->default(false)->after('is_preview');
|
||||
});
|
||||
|
||||
// Restore data to is_buildtime_only based on new fields
|
||||
DB::table('environment_variables')
|
||||
->where('is_runtime', false)
|
||||
->where('is_buildtime', true)
|
||||
->update(['is_buildtime_only' => true]);
|
||||
|
||||
DB::table('environment_variables')
|
||||
->where('is_runtime', true)
|
||||
->update(['is_buildtime_only' => false]);
|
||||
|
||||
// Remove new columns
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
$table->dropColumn(['is_runtime', 'is_buildtime']);
|
||||
});
|
||||
}
|
||||
};
|
@@ -8360,7 +8360,10 @@
|
||||
"is_preview": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_buildtime_only": {
|
||||
"is_runtime": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_buildtime": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_shared": {
|
||||
|
@@ -5411,7 +5411,9 @@ components:
|
||||
type: boolean
|
||||
is_preview:
|
||||
type: boolean
|
||||
is_buildtime_only:
|
||||
is_runtime:
|
||||
type: boolean
|
||||
is_buildtime:
|
||||
type: boolean
|
||||
is_shared:
|
||||
type: boolean
|
||||
|
@@ -6,10 +6,31 @@
|
||||
@apply hidden!;
|
||||
}
|
||||
|
||||
@utility apexcharts-grid-borders {
|
||||
@apply dark:hidden!;
|
||||
}
|
||||
|
||||
@utility apexcharts-xaxistooltip {
|
||||
@apply hidden!;
|
||||
}
|
||||
|
||||
@utility apexcharts-tooltip-custom {
|
||||
@apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
@utility apexcharts-tooltip-custom-value {
|
||||
@apply text-neutral-700 dark:text-neutral-300 mb-1;
|
||||
}
|
||||
|
||||
@utility apexcharts-tooltip-value-bold {
|
||||
@apply font-bold text-black dark:text-white;
|
||||
}
|
||||
|
||||
@utility apexcharts-tooltip-custom-title {
|
||||
@apply text-xs text-neutral-500 dark:text-neutral-400 font-medium;
|
||||
}
|
||||
|
||||
@utility input-sticky {
|
||||
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300;
|
||||
}
|
||||
|
@@ -0,0 +1,32 @@
|
||||
@props(['problematicVariables' => []])
|
||||
|
||||
<template x-data="{
|
||||
problematicVars: @js($problematicVariables),
|
||||
get showWarning() {
|
||||
const currentKey = $wire.key;
|
||||
const isBuildtime = $wire.is_buildtime;
|
||||
|
||||
if (!isBuildtime || !currentKey) return false;
|
||||
if (!this.problematicVars.hasOwnProperty(currentKey)) return false;
|
||||
|
||||
// Always show warning for known problematic variables when set as buildtime
|
||||
return true;
|
||||
},
|
||||
get warningMessage() {
|
||||
if (!this.showWarning) return null;
|
||||
const config = this.problematicVars[$wire.key];
|
||||
if (!config) return null;
|
||||
return config.issue;
|
||||
},
|
||||
get recommendation() {
|
||||
if (!this.showWarning) return null;
|
||||
const config = this.problematicVars[$wire.key];
|
||||
if (!config) return null;
|
||||
return `Recommendation: ${config.recommendation}`;
|
||||
}
|
||||
}" x-if="showWarning">
|
||||
<div class="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="text-sm text-yellow-700 dark:text-yellow-300" x-text="warningMessage"></div>
|
||||
<div class="text-sm text-yellow-700 dark:text-yellow-300" x-text="recommendation"></div>
|
||||
</div>
|
||||
</template>
|
@@ -11,6 +11,7 @@
|
||||
'content' => null,
|
||||
'checkboxes' => [],
|
||||
'actions' => [],
|
||||
'warningMessage' => null,
|
||||
'confirmWithText' => true,
|
||||
'confirmationText' => 'Confirm Deletion',
|
||||
'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below',
|
||||
@@ -228,7 +229,7 @@
|
||||
<div x-show="step === 2">
|
||||
<div class="p-4 mb-4 text-white border-l-4 border-red-500 bg-error" role="alert">
|
||||
<p class="font-bold">Warning</p>
|
||||
<p>This operation is permanent and cannot be undone. Please think again before proceeding!
|
||||
<p>{!! $warningMessage ?: 'This operation is permanent and cannot be undone. Please think again before proceeding!' !!}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-4">The following actions will be performed:</div>
|
||||
|
@@ -59,20 +59,20 @@
|
||||
if (this.zoom === '90') {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
html {
|
||||
font-size: 93.75%;
|
||||
}
|
||||
|
||||
:root {
|
||||
--vh: 1vh;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
html {
|
||||
font-size: 87.5%;
|
||||
font-size: 93.75%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
:root {
|
||||
--vh: 1vh;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
html {
|
||||
font-size: 87.5%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@
|
||||
<div class="text-2xl font-bold tracking-wide dark:text-white">Coolify</div>
|
||||
<x-version />
|
||||
</div>
|
||||
<div>
|
||||
<livewire:global-search />
|
||||
</div>
|
||||
<livewire:settings-dropdown />
|
||||
</div>
|
||||
<div class="px-2 pt-2 pb-7">
|
||||
|
@@ -397,28 +397,28 @@
|
||||
:class="{ 'p-4': !toast.html, 'p-0': toast.html }">
|
||||
<template x-if="!toast.html">
|
||||
<div class="relative w-full">
|
||||
<div class="flex items-start"
|
||||
<div class="flex items-center"
|
||||
:class="{ 'text-green-500': toast.type=='success', 'text-blue-500': toast.type=='info', 'text-orange-400': toast.type=='warning', 'text-red-500': toast.type=='danger', 'text-gray-800': toast.type=='default' }">
|
||||
|
||||
<svg x-show="toast.type=='success'" class="w-[18px] h-[18px] mr-1.5 -ml-1"
|
||||
<svg x-show="toast.type=='success'" class="w-[18px] h-[18px] mr-1.5 -ml-1 flex-shrink-0"
|
||||
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM16.7744 9.63269C17.1238 9.20501 17.0604 8.57503 16.6327 8.22559C16.2051 7.87615 15.5751 7.93957 15.2256 8.36725L10.6321 13.9892L8.65936 12.2524C8.24484 11.8874 7.61295 11.9276 7.248 12.3421C6.88304 12.7566 6.92322 13.3885 7.33774 13.7535L9.31046 15.4903C10.1612 16.2393 11.4637 16.1324 12.1808 15.2547L16.7744 9.63269Z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
<svg x-show="toast.type=='info'" class="w-[18px] h-[18px] mr-1.5 -ml-1"
|
||||
<svg x-show="toast.type=='info'" class="w-[18px] h-[18px] mr-1.5 -ml-1 flex-shrink-0"
|
||||
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM12 9C12.5523 9 13 8.55228 13 8C13 7.44772 12.5523 7 12 7C11.4477 7 11 7.44772 11 8C11 8.55228 11.4477 9 12 9ZM13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12V16C11 16.5523 11.4477 17 12 17C12.5523 17 13 16.5523 13 16V12Z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
<svg x-show="toast.type=='warning'" class="w-[18px] h-[18px] mr-1.5 -ml-1"
|
||||
<svg x-show="toast.type=='warning'" class="w-[18px] h-[18px] mr-1.5 -ml-1 flex-shrink-0"
|
||||
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M9.44829 4.46472C10.5836 2.51208 13.4105 2.51168 14.5464 4.46401L21.5988 16.5855C22.7423 18.5509 21.3145 21 19.05 21L4.94967 21C2.68547 21 1.25762 18.5516 2.4004 16.5862L9.44829 4.46472ZM11.9995 8C12.5518 8 12.9995 8.44772 12.9995 9V13C12.9995 13.5523 12.5518 14 11.9995 14C11.4473 14 10.9995 13.5523 10.9995 13V9C10.9995 8.44772 11.4473 8 11.9995 8ZM12.0009 15.99C11.4486 15.9892 11.0003 16.4363 10.9995 16.9886L10.9995 16.9986C10.9987 17.5509 11.4458 17.9992 11.9981 18C12.5504 18.0008 12.9987 17.5537 12.9995 17.0014L12.9995 16.9914C13.0003 16.4391 12.5532 15.9908 12.0009 15.99Z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
<svg x-show="toast.type=='danger'" class="w-[18px] h-[18px] mr-1.5 -ml-1"
|
||||
<svg x-show="toast.type=='danger'" class="w-[18px] h-[18px] mr-1.5 -ml-1 flex-shrink-0"
|
||||
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9996 7C12.5519 7 12.9996 7.44772 12.9996 8V12C12.9996 12.5523 12.5519 13 11.9996 13C11.4474 13 10.9996 12.5523 10.9996 12V8C10.9996 7.44772 11.4474 7 11.9996 7ZM12.001 14.99C11.4488 14.9892 11.0004 15.4363 10.9997 15.9886L10.9996 15.9986C10.9989 16.5509 11.446 16.9992 11.9982 17C12.5505 17.0008 12.9989 16.5537 12.9996 16.0014L12.9996 15.9914C13.0004 15.4391 12.5533 14.9908 12.001 14.99Z"
|
||||
|
@@ -35,9 +35,9 @@
|
||||
@endphp
|
||||
<title>{{ $name }}{{ $title ?? 'Coolify' }}</title>
|
||||
@env('local')
|
||||
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/x-icon" />
|
||||
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/png" />
|
||||
@else
|
||||
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/x-icon" />
|
||||
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
|
||||
@endenv
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@vite(['resources/js/app.js', 'resources/css/app.css'])
|
||||
@@ -138,7 +138,8 @@
|
||||
}
|
||||
}
|
||||
let theme = localStorage.theme
|
||||
let baseColor = '#FCD452'
|
||||
let cpuColor = '#1e90ff'
|
||||
let ramColor = '#00ced1'
|
||||
let textColor = '#ffffff'
|
||||
let editorBackground = '#181818'
|
||||
let editorTheme = 'blackboard'
|
||||
@@ -149,12 +150,14 @@
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
if (theme == 'dark') {
|
||||
baseColor = '#FCD452'
|
||||
cpuColor = '#1e90ff'
|
||||
ramColor = '#00ced1'
|
||||
textColor = '#ffffff'
|
||||
editorBackground = '#181818'
|
||||
editorTheme = 'blackboard'
|
||||
} else {
|
||||
baseColor = 'black'
|
||||
cpuColor = '#1e90ff'
|
||||
ramColor = '#00ced1'
|
||||
textColor = '#000000'
|
||||
editorBackground = '#ffffff'
|
||||
editorTheme = null
|
||||
|
236
resources/views/livewire/global-search.blade.php
Normal file
236
resources/views/livewire/global-search.blade.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<div x-data="{
|
||||
modalOpen: false,
|
||||
selectedIndex: -1,
|
||||
openModal() {
|
||||
this.modalOpen = true;
|
||||
this.selectedIndex = -1;
|
||||
@this.openSearchModal();
|
||||
},
|
||||
closeModal() {
|
||||
this.modalOpen = false;
|
||||
this.selectedIndex = -1;
|
||||
@this.closeSearchModal();
|
||||
},
|
||||
navigateResults(direction) {
|
||||
const results = document.querySelectorAll('.search-result-item');
|
||||
if (results.length === 0) return;
|
||||
|
||||
if (direction === 'down') {
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, results.length - 1);
|
||||
} else if (direction === 'up') {
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
||||
}
|
||||
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < results.length) {
|
||||
results[this.selectedIndex].focus();
|
||||
results[this.selectedIndex].scrollIntoView({ block: 'nearest' });
|
||||
} else if (this.selectedIndex === -1) {
|
||||
this.$refs.searchInput?.focus();
|
||||
}
|
||||
},
|
||||
init() {
|
||||
// Listen for / key press globally
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(e.target.tagName) && !this.modalOpen) {
|
||||
e.preventDefault();
|
||||
this.openModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for Cmd+K or Ctrl+K globally
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (this.modalOpen) {
|
||||
this.closeModal();
|
||||
} else {
|
||||
this.openModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for Escape key to close modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.modalOpen) {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for arrow keys when modal is open
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!this.modalOpen) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.navigateResults('down');
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.navigateResults('up');
|
||||
}
|
||||
});
|
||||
}
|
||||
}">
|
||||
<!-- Search bar in navbar -->
|
||||
<div class="flex justify-center">
|
||||
<button @click="openModal()" type="button" title="Search (Press / or ⌘K)"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 bg-neutral-100 dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-neutral-500 dark:text-neutral-400" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<kbd
|
||||
class="px-1 py-0.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400 bg-neutral-200 dark:bg-coolgray-200 rounded">/</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal overlay -->
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-cloak
|
||||
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen">
|
||||
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs">
|
||||
</div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300"
|
||||
@click.stop>
|
||||
|
||||
<div class="flex justify-between items-center pb-3">
|
||||
<h3 class="pr-8 text-2xl font-bold">Search</h3>
|
||||
<button @click="closeModal()"
|
||||
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative w-auto">
|
||||
<input type="text" wire:model.live.debounce.500ms="searchQuery"
|
||||
placeholder="Type to search for applications, services, databases, and servers..."
|
||||
x-ref="searchInput" x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })" class="w-full input mb-4" />
|
||||
|
||||
<!-- Search results -->
|
||||
<div class="relative min-h-[330px] max-h-[400px] overflow-y-auto scrollbar">
|
||||
<!-- Loading indicator -->
|
||||
<div wire:loading.flex wire:target="searchQuery"
|
||||
class="min-h-[330px] items-center justify-center">
|
||||
<div class="text-center">
|
||||
<svg class="animate-spin mx-auto h-8 w-8 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
||||
stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Searching...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results content - hidden while loading -->
|
||||
<div wire:loading.remove wire:target="searchQuery">
|
||||
@if (strlen($searchQuery) >= 2 && count($searchResults) > 0)
|
||||
<div class="space-y-1 my-4 pb-4">
|
||||
@foreach ($searchResults as $index => $result)
|
||||
<a href="{{ $result['link'] ?? '#' }}"
|
||||
class="search-result-item block p-3 mx-1 hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:ring-1 focus:ring-coollabs focus:bg-neutral-100 dark:focus:bg-coolgray-200 ">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-neutral-900 dark:text-white">
|
||||
{{ $result['name'] }}
|
||||
</span>
|
||||
@if ($result['type'] === 'server')
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
|
||||
Server
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (!empty($result['project']) && !empty($result['environment']))
|
||||
<span
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{{ $result['project'] }} / {{ $result['environment'] }}
|
||||
</span>
|
||||
@endif
|
||||
@if ($result['type'] === 'application')
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
|
||||
Application
|
||||
</span>
|
||||
@elseif ($result['type'] === 'service')
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
|
||||
Service
|
||||
</span>
|
||||
@elseif ($result['type'] === 'database')
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
|
||||
{{ ucfirst($result['subtype'] ?? 'Database') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if (!empty($result['description']))
|
||||
<div
|
||||
class="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{{ Str::limit($result['description'], 100) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="shrink-0 ml-2 h-4 w-4 text-neutral-400" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif (strlen($searchQuery) >= 2 && count($searchResults) === 0)
|
||||
<div class="flex items-center justify-center min-h-[330px]">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
No results found for "<strong>{{ $searchQuery }}</strong>"
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">
|
||||
Try different keywords or check the spelling
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif (strlen($searchQuery) > 0 && strlen($searchQuery) < 2)
|
||||
<div class="flex items-center justify-center min-h-[330px]">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Type at least 2 characters to search
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center justify-center min-h-[330px]">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Start typing to search
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">
|
||||
Search for applications, services, databases, and servers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
@@ -113,7 +113,7 @@
|
||||
])>
|
||||
<span x-show="showTimestamps" class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span>
|
||||
<span @class([
|
||||
'text-coollabs dark:text-warning' => $line['hidden'],
|
||||
'text-success dark:text-warning' => $line['hidden'],
|
||||
'text-red-500' => $line['stderr'],
|
||||
'font-bold' => isset($line['command']) && $line['command'],
|
||||
'whitespace-pre-wrap',
|
||||
|
@@ -3,15 +3,23 @@
|
||||
<x-forms.textarea x-show="$wire.is_multiline === true" x-cloak id="value" label="Value" required />
|
||||
<x-forms.input x-show="$wire.is_multiline === false" x-cloak placeholder="production" id="value"
|
||||
x-bind:label="$wire.is_multiline === false && 'Value'" required />
|
||||
<x-forms.checkbox id="is_multiline" label="Is Multiline?" />
|
||||
@if (!$shared)
|
||||
<x-forms.checkbox id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
|
||||
@if (!$shared || $isNixpacks)
|
||||
<x-forms.checkbox id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
|
||||
<x-environment-variable-warning :problematic-variables="$problematicVariables" />
|
||||
|
||||
<x-forms.checkbox id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
|
||||
<x-forms.checkbox id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.button type="submit" @click="slideOverOpen=false">
|
||||
Save
|
||||
</x-forms.button>
|
||||
|
@@ -13,17 +13,32 @@
|
||||
@endcan
|
||||
</div>
|
||||
<div>Environment variables (secrets) for this resource. </div>
|
||||
@if ($resourceClass === 'App\Models\Application' && data_get($resource, 'build_pack') !== 'dockercompose')
|
||||
<div class="w-64 pt-2">
|
||||
@can('manageEnvironment', $resource)
|
||||
<x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically"
|
||||
helper="Turn this off if one environment is dependent on an other. It will be sorted by creation order (like you pasted them or in the order you created them)."
|
||||
instantSave></x-forms.checkbox>
|
||||
@else
|
||||
<x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically"
|
||||
helper="Turn this off if one environment is dependent on an other. It will be sorted by creation order (like you pasted them or in the order you created them)."
|
||||
disabled></x-forms.checkbox>
|
||||
@endcan
|
||||
@if ($resourceClass === 'App\Models\Application')
|
||||
<div class="flex flex-col gap-2 pt-2">
|
||||
@if (data_get($resource, 'build_pack') !== 'dockercompose')
|
||||
<div class="w-64">
|
||||
@can('manageEnvironment', $resource)
|
||||
<x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically"
|
||||
helper="Turn this off if one environment is dependent on another. It will be sorted by creation order (like you pasted them or in the order you created them)."
|
||||
instantSave></x-forms.checkbox>
|
||||
@else
|
||||
<x-forms.checkbox id="is_env_sorting_enabled" label="Sort alphabetically"
|
||||
helper="Turn this off if one environment is dependent on another. It will be sorted by creation order (like you pasted them or in the order you created them)."
|
||||
disabled></x-forms.checkbox>
|
||||
@endcan
|
||||
</div>
|
||||
@endif
|
||||
<div class="w-64">
|
||||
@can('manageEnvironment', $resource)
|
||||
<x-forms.checkbox id="use_build_secrets" label="Use Docker Build Secrets"
|
||||
helper="Enable Docker BuildKit secrets for enhanced security during builds. Secrets won't be exposed in the final image. Requires Docker 18.09+ with BuildKit support."
|
||||
instantSave></x-forms.checkbox>
|
||||
@else
|
||||
<x-forms.checkbox id="use_build_secrets" label="Use Docker Build Secrets"
|
||||
helper="Enable Docker BuildKit secrets for enhanced security during builds. Secrets won't be exposed in the final image. Requires Docker 18.09+ with BuildKit support."
|
||||
disabled></x-forms.checkbox>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose')
|
||||
|
@@ -7,7 +7,7 @@
|
||||
@if ($isLocked)
|
||||
<div class="flex flex-1 w-full gap-2">
|
||||
<x-forms.input disabled id="key" />
|
||||
<svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class="icon my-1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<path d="M5 13a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6z" />
|
||||
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0-2 0m-3-5V7a4 4 0 1 1 8 0v4" />
|
||||
@@ -21,6 +21,95 @@
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@endcan
|
||||
</div>
|
||||
@can('update', $this->env)
|
||||
<div class="flex flex-col w-full gap-3">
|
||||
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
|
||||
@if (!$is_redis_credential)
|
||||
@if ($type === 'service')
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($isSharedVariable)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@endif
|
||||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col w-full gap-3">
|
||||
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
|
||||
@if (!$is_redis_credential)
|
||||
@if ($type === 'service')
|
||||
<x-forms.checkbox disabled id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($isSharedVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@else
|
||||
<x-forms.checkbox disabled id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
@else
|
||||
@can('update', $this->env)
|
||||
@if ($isDisabled)
|
||||
@@ -55,101 +144,114 @@
|
||||
</div>
|
||||
@endcan
|
||||
@can('update', $this->env)
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
@if (!$is_redis_credential)
|
||||
@if ($type === 'service')
|
||||
<x-forms.checkbox instantSave id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<div class="flex flex-col w-full gap-3">
|
||||
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
|
||||
@if (!$is_redis_credential)
|
||||
@if ($type === 'service')
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($isSharedVariable)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
<x-forms.checkbox instantSave id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@if ($isSharedVariable)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@endif
|
||||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
<div class="flex-1"></div>
|
||||
@if ($isDisabled)
|
||||
<x-forms.button disabled type="submit">
|
||||
Update
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click='lock'>
|
||||
Lock
|
||||
</x-forms.button>
|
||||
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
|
||||
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
|
||||
confirmationText="{{ $key }}" buttonFullWidth="true"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
|
||||
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@else
|
||||
<x-forms.button type="submit">
|
||||
Update
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click='lock'>
|
||||
Lock
|
||||
</x-forms.button>
|
||||
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
|
||||
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
|
||||
confirmationText="{{ $key }}" buttonFullWidth="true"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
|
||||
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@endif
|
||||
</div>
|
||||
<x-environment-variable-warning :problematic-variables="$problematicVariables" />
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
@if ($isDisabled)
|
||||
<x-forms.button disabled type="submit">Update</x-forms.button>
|
||||
<x-forms.button wire:click='lock'>Lock</x-forms.button>
|
||||
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
|
||||
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
|
||||
confirmationText="{{ $key }}" buttonFullWidth="true"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
|
||||
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@else
|
||||
<x-forms.button type="submit">Update</x-forms.button>
|
||||
<x-forms.button wire:click='lock'>Lock</x-forms.button>
|
||||
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
|
||||
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
|
||||
confirmationText="{{ $key }}" buttonFullWidth="true"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
|
||||
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col w-full gap-2 flex-wrap lg:flex-row">
|
||||
@if (!$is_redis_credential)
|
||||
@if ($type === 'service')
|
||||
<x-forms.checkbox disabled id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<div class="flex flex-col w-full gap-3">
|
||||
<div class="flex w-full items-center gap-4 overflow-x-auto whitespace-nowrap">
|
||||
@if (!$is_redis_credential)
|
||||
@if ($type === 'service')
|
||||
<x-forms.checkbox disabled id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($isSharedVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
<x-forms.checkbox disabled id="is_buildtime_only"
|
||||
helper="This variable will ONLY be available during build and not in the running container. Useful for build secrets that shouldn't persist at runtime."
|
||||
label="Buildtime Only?" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@if ($isSharedVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@else
|
||||
<x-forms.checkbox disabled id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
<div class="flex-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
|
@@ -1,21 +1,20 @@
|
||||
<div>
|
||||
<div class="flex items-center gap-2 ">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Metrics</h2>
|
||||
</div>
|
||||
<div class="pb-4">Basic metrics for your container.</div>
|
||||
@if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose')
|
||||
<div class="alert alert-warning">Metrics are not available for Docker Compose applications yet!</div>
|
||||
@elseif(!$resource->destination->server->isMetricsEnabled())
|
||||
<div class="alert alert-warning">Metrics are only available for servers with Sentinel & Metrics enabled!</div>
|
||||
<div> Go to <a class="underline dark:text-white"
|
||||
href="{{ route('server.show', $resource->destination->server->uuid) }}">Server settings</a> to
|
||||
enable
|
||||
it.</div>
|
||||
@else
|
||||
@if (!str($resource->status)->contains('running'))
|
||||
<div class="alert alert-warning">Metrics are only available when this resource is running!</div>
|
||||
<div class="pb-4">Basic metrics for your application container.</div>
|
||||
<div>
|
||||
@if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose')
|
||||
<div class="alert alert-warning">Metrics are not available for Docker Compose applications yet!</div>
|
||||
@elseif(!$resource->destination->server->isMetricsEnabled())
|
||||
<div class="alert alert-warning">Metrics are only available for servers with Sentinel & Metrics enabled!</div>
|
||||
<div>Go to <a class="underline dark:text-white" href="{{ route('server.show', $resource->destination->server->uuid) }}">Server settings</a> to enable it.</div>
|
||||
@else
|
||||
<x-forms.select label="Interval" wire:change="setInterval" id="interval">
|
||||
@if (!str($resource->status)->contains('running'))
|
||||
<div class="alert alert-warning">Metrics are only available when the application container is running!</div>
|
||||
@else
|
||||
<div>
|
||||
<x-forms.select label="Interval" wire:change="setInterval" id="interval">
|
||||
<option value="5">5 minutes (live)</option>
|
||||
<option value="10">10 minutes (live)</option>
|
||||
<option value="30">30 minutes</option>
|
||||
@@ -26,7 +25,7 @@
|
||||
</x-forms.select>
|
||||
<div @if ($poll) wire:poll.5000ms='pollData' @endif x-init="$wire.loadData()"
|
||||
class="pt-5">
|
||||
<h4>CPU (%)</h4>
|
||||
<h4>CPU Usage</h4>
|
||||
<div wire:ignore id="{!! $chartId !!}-cpu"></div>
|
||||
|
||||
<script>
|
||||
@@ -34,6 +33,7 @@
|
||||
const optionsServerCpu = {
|
||||
stroke: {
|
||||
curve: 'straight',
|
||||
width: 2,
|
||||
},
|
||||
chart: {
|
||||
height: '150px',
|
||||
@@ -52,7 +52,7 @@
|
||||
},
|
||||
},
|
||||
animations: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
@@ -68,74 +68,90 @@
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: '',
|
||||
},
|
||||
colors: [baseColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
},
|
||||
series: [{
|
||||
name: "CPU %",
|
||||
data: []
|
||||
}],
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
color: textColor,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
marker: {
|
||||
show: false,
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
}
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: '',
|
||||
},
|
||||
colors: [cpuColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
},
|
||||
series: [{
|
||||
name: "CPU %",
|
||||
data: []
|
||||
}],
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
color: textColor,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
marker: {
|
||||
show: false,
|
||||
},
|
||||
custom: function({ series, seriesIndex, dataPointIndex, w }) {
|
||||
const value = series[seriesIndex][dataPointIndex];
|
||||
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
|
||||
const date = new Date(timestamp);
|
||||
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
|
||||
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
|
||||
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
|
||||
date.getUTCFullYear() + '-' +
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getUTCDate()).padStart(2, '0');
|
||||
return '<div class="apexcharts-tooltip-custom">' +
|
||||
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
|
||||
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu);
|
||||
serverCpuChart.render();
|
||||
document.addEventListener('livewire:init', () => {
|
||||
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
|
||||
checkTheme();
|
||||
serverCpuChart.updateOptions({
|
||||
series: [{
|
||||
data: chartData[0].seriesData,
|
||||
}],
|
||||
colors: [baseColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
show: true,
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
color: textColor,
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu);
|
||||
serverCpuChart.render();
|
||||
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
|
||||
checkTheme();
|
||||
serverCpuChart.updateOptions({
|
||||
series: [{
|
||||
data: chartData[0].seriesData,
|
||||
}],
|
||||
colors: [cpuColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
show: true,
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
},
|
||||
formatter: function(value) {
|
||||
return Math.round(value) + ' %';
|
||||
}
|
||||
}
|
||||
},
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
color: textColor,
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<h3>Memory (MB)</h3>
|
||||
<h4>Memory Usage</h4>
|
||||
<div wire:ignore id="{!! $chartId !!}-memory"></div>
|
||||
|
||||
<script>
|
||||
@@ -143,6 +159,7 @@
|
||||
const optionsServerMemory = {
|
||||
stroke: {
|
||||
curve: 'straight',
|
||||
width: 2,
|
||||
},
|
||||
chart: {
|
||||
height: '150px',
|
||||
@@ -161,7 +178,7 @@
|
||||
},
|
||||
},
|
||||
animations: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
@@ -177,81 +194,99 @@
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: '',
|
||||
},
|
||||
colors: [baseColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: "Memory (MB)",
|
||||
data: []
|
||||
}],
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
color: textColor,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
marker: {
|
||||
show: false,
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
}
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: '',
|
||||
},
|
||||
colors: [ramColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: "Memory (MB)",
|
||||
data: []
|
||||
}],
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
color: textColor,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
marker: {
|
||||
show: false,
|
||||
},
|
||||
custom: function({ series, seriesIndex, dataPointIndex, w }) {
|
||||
const value = series[seriesIndex][dataPointIndex];
|
||||
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
|
||||
const date = new Date(timestamp);
|
||||
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
|
||||
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
|
||||
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
|
||||
date.getUTCFullYear() + '-' +
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getUTCDate()).padStart(2, '0');
|
||||
return '<div class="apexcharts-tooltip-custom">' +
|
||||
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + ' MB</span></div>' +
|
||||
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
}
|
||||
}
|
||||
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
|
||||
optionsServerMemory);
|
||||
serverMemoryChart.render();
|
||||
document.addEventListener('livewire:init', () => {
|
||||
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
|
||||
checkTheme();
|
||||
serverMemoryChart.updateOptions({
|
||||
series: [{
|
||||
data: chartData[0].seriesData,
|
||||
}],
|
||||
colors: [baseColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
min: 0,
|
||||
show: true,
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
color: textColor,
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
|
||||
optionsServerMemory);
|
||||
serverMemoryChart.render();
|
||||
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
|
||||
checkTheme();
|
||||
serverMemoryChart.updateOptions({
|
||||
series: [{
|
||||
data: chartData[0].seriesData,
|
||||
}],
|
||||
colors: [ramColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
min: 0,
|
||||
show: true,
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
},
|
||||
formatter: function(value) {
|
||||
return Math.round(value) + ' MB';
|
||||
}
|
||||
}
|
||||
},
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
color: textColor,
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -7,7 +7,7 @@
|
||||
<x-server.sidebar :server="$server" activeMenu="metrics" />
|
||||
<div class="w-full">
|
||||
<h2>Metrics</h2>
|
||||
<div class="pb-4">Basic metrics for your container.</div>
|
||||
<div class="pb-4">Basic metrics for your server.</div>
|
||||
@if ($server->isMetricsEnabled())
|
||||
<div @if ($poll) wire:poll.5000ms='pollData' @endif x-init="$wire.loadData()">
|
||||
<x-forms.select label="Interval" wire:change="setInterval" id="interval">
|
||||
@@ -19,7 +19,7 @@
|
||||
<option value="10080">1 week</option>
|
||||
<option value="43200">30 days</option>
|
||||
</x-forms.select>
|
||||
<h4 class="pt-4">CPU (%)</h4>
|
||||
<h4 class="pt-4">CPU Usage</h4>
|
||||
<div wire:ignore id="{!! $chartId !!}-cpu"></div>
|
||||
|
||||
<script>
|
||||
@@ -27,6 +27,7 @@
|
||||
const optionsServerCpu = {
|
||||
stroke: {
|
||||
curve: 'straight',
|
||||
width: 2,
|
||||
},
|
||||
chart: {
|
||||
height: '150px',
|
||||
@@ -45,7 +46,7 @@
|
||||
},
|
||||
},
|
||||
animations: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
@@ -61,16 +62,16 @@
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: '',
|
||||
},
|
||||
colors: [baseColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
},
|
||||
series: [{
|
||||
name: 'CPU %',
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: '',
|
||||
},
|
||||
colors: [cpuColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
},
|
||||
series: [{
|
||||
name: 'CPU %',
|
||||
data: []
|
||||
}],
|
||||
noData: {
|
||||
@@ -79,12 +80,27 @@
|
||||
color: textColor,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
marker: {
|
||||
show: false,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
marker: {
|
||||
show: false,
|
||||
},
|
||||
custom: function({ series, seriesIndex, dataPointIndex, w }) {
|
||||
const value = series[seriesIndex][dataPointIndex];
|
||||
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
|
||||
const date = new Date(timestamp);
|
||||
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
|
||||
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
|
||||
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
|
||||
date.getUTCFullYear() + '-' +
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getUTCDate()).padStart(2, '0');
|
||||
return '<div class="apexcharts-tooltip-custom">' +
|
||||
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
|
||||
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
}
|
||||
@@ -95,11 +111,11 @@
|
||||
document.addEventListener('livewire:init', () => {
|
||||
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
|
||||
checkTheme();
|
||||
serverCpuChart.updateOptions({
|
||||
series: [{
|
||||
data: chartData[0].seriesData,
|
||||
}],
|
||||
colors: [baseColor],
|
||||
serverCpuChart.updateOptions({
|
||||
series: [{
|
||||
data: chartData[0].seriesData,
|
||||
}],
|
||||
colors: [cpuColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
@@ -109,15 +125,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
show: true,
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
show: true,
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
},
|
||||
formatter: function(value) {
|
||||
return Math.round(value) + ' %';
|
||||
}
|
||||
}
|
||||
},
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
@@ -130,7 +149,7 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h4>Memory (%)</h4>
|
||||
<h4>Memory Usage</h4>
|
||||
<div wire:ignore id="{!! $chartId !!}-memory"></div>
|
||||
|
||||
<script>
|
||||
@@ -138,6 +157,7 @@
|
||||
const optionsServerMemory = {
|
||||
stroke: {
|
||||
curve: 'straight',
|
||||
width: 2,
|
||||
},
|
||||
chart: {
|
||||
height: '150px',
|
||||
@@ -156,7 +176,7 @@
|
||||
},
|
||||
},
|
||||
animations: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
@@ -172,15 +192,15 @@
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: '',
|
||||
},
|
||||
colors: [baseColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
show: true,
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: '',
|
||||
},
|
||||
colors: [ramColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
@@ -196,12 +216,27 @@
|
||||
color: textColor,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
marker: {
|
||||
show: false,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
marker: {
|
||||
show: false,
|
||||
},
|
||||
custom: function({ series, seriesIndex, dataPointIndex, w }) {
|
||||
const value = series[seriesIndex][dataPointIndex];
|
||||
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
|
||||
const date = new Date(timestamp);
|
||||
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
|
||||
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
|
||||
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
|
||||
date.getUTCFullYear() + '-' +
|
||||
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getUTCDate()).padStart(2, '0');
|
||||
return '<div class="apexcharts-tooltip-custom">' +
|
||||
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
|
||||
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: false
|
||||
}
|
||||
@@ -212,11 +247,11 @@
|
||||
document.addEventListener('livewire:init', () => {
|
||||
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
|
||||
checkTheme();
|
||||
serverMemoryChart.updateOptions({
|
||||
series: [{
|
||||
data: chartData[0].seriesData,
|
||||
}],
|
||||
colors: [baseColor],
|
||||
serverMemoryChart.updateOptions({
|
||||
series: [{
|
||||
data: chartData[0].seriesData,
|
||||
}],
|
||||
colors: [ramColor],
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
labels: {
|
||||
@@ -226,16 +261,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
min: 0,
|
||||
show: true,
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
}
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
min: 0,
|
||||
show: true,
|
||||
labels: {
|
||||
show: true,
|
||||
style: {
|
||||
colors: textColor,
|
||||
},
|
||||
formatter: function(value) {
|
||||
return Math.round(value) + ' %';
|
||||
}
|
||||
}
|
||||
},
|
||||
noData: {
|
||||
text: 'Loading...',
|
||||
style: {
|
||||
|
@@ -7,23 +7,23 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Configuration</h2>
|
||||
@if ($server->proxy->status === 'exited' || $server->proxy->status === 'removing')
|
||||
<x-forms.button canGate="update" :canResource="$server" wire:click.prevent="changeProxy">Switch
|
||||
Proxy</x-forms.button>
|
||||
@can('update', $server)
|
||||
<x-modal-confirmation title="Confirm Proxy Switching?" buttonTitle="Switch Proxy"
|
||||
submitAction="changeProxy" :actions="['Custom proxy configurations may be reset to their default settings.']"
|
||||
warningMessage="This operation may cause issues. Please refer to the guide <a href='https://coolify.io/docs/knowledge-base/server/proxies#switch-between-proxies' target='_blank' class='underline text-white'>switching between proxies</a> before proceeding!"
|
||||
step2ButtonText="Switch Proxy" :confirmWithText="false" :confirmWithPassword="false">
|
||||
</x-modal-confirmation>
|
||||
@endcan
|
||||
@else
|
||||
<x-forms.button canGate="update" :canResource="$server" disabled
|
||||
wire:click.prevent="changeProxy">Switch Proxy</x-forms.button>
|
||||
<x-forms.button canGate="update" :canResource="$server"
|
||||
wire:click="$dispatch('error', 'Currently running proxy must be stopped before switching proxy')">Switch
|
||||
Proxy</x-forms.button>
|
||||
@endif
|
||||
<x-forms.button canGate="update" :canResource="$server" type="submit">Save</x-forms.button>
|
||||
</div>
|
||||
<div class="pb-4 "> <svg class="inline-flex w-6 h-6 mr-2 dark:text-warning" viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
|
||||
</svg>Before switching proxies, please read <a class="underline dark:text-white"
|
||||
href="https://coolify.io/docs/knowledge-base/server/proxies#switch-between-proxies">this</a>.
|
||||
</div>
|
||||
<div class="subtitle">Configure your proxy settings and advanced options.</div>
|
||||
<h3>Advanced</h3>
|
||||
<div class="pb-4 w-96">
|
||||
<div class="pb-6 w-96">
|
||||
<x-forms.checkbox canGate="update" :canResource="$server"
|
||||
helper="If set, all resources will only have docker container labels for {{ str($server->proxyType())->title() }}.<br>For applications, labels needs to be regenerated manually. <br>Resources needs to be restarted."
|
||||
id="server.settings.generate_exact_labels"
|
||||
@@ -36,10 +36,31 @@
|
||||
id="redirectUrl" label="Redirect to (optional)" />
|
||||
@endif
|
||||
</div>
|
||||
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
|
||||
<h3>Traefik</h3>
|
||||
@elseif ($server->proxyType() === 'CADDY')
|
||||
<h3>Caddy</h3>
|
||||
@php
|
||||
$proxyTitle =
|
||||
$server->proxyType() === ProxyTypes::TRAEFIK->value
|
||||
? 'Traefik (Coolify Proxy)'
|
||||
: 'Caddy (Coolify Proxy)';
|
||||
@endphp
|
||||
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value || $server->proxyType() === 'CADDY')
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>{{ $proxyTitle }}</h3>
|
||||
@if ($proxySettings)
|
||||
@can('update', $server)
|
||||
<x-modal-confirmation title="Reset Proxy Configuration?"
|
||||
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
|
||||
:actions="[
|
||||
'Reset proxy configuration to default settings',
|
||||
'All custom configurations will be lost',
|
||||
'Custom ports and entrypoints will be removed',
|
||||
]" confirmationText="{{ $server->name }}"
|
||||
confirmationLabel="Please confirm by entering the server name below"
|
||||
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
|
||||
:confirmWithPassword="false" :confirmWithText="true">
|
||||
</x-modal-confirmation>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if (
|
||||
$server->proxy->last_applied_settings &&
|
||||
@@ -53,25 +74,11 @@
|
||||
</div>
|
||||
<div wire:loading.remove wire:target="loadProxyConfiguration">
|
||||
@if ($proxySettings)
|
||||
<div class="flex flex-col gap-2 pt-4">
|
||||
<div class="flex flex-col gap-2 pt-2">
|
||||
<x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor
|
||||
monacoEditorLanguage="yaml"
|
||||
label="Configuration file ({{ $this->configurationFilePath }})" name="proxySettings"
|
||||
id="proxySettings" rows="30" />
|
||||
@can('update', $server)
|
||||
<x-modal-confirmation title="Reset Proxy Configuration?"
|
||||
buttonTitle="Reset configuration to default" isErrorButton
|
||||
submitAction="resetProxyConfiguration" :actions="[
|
||||
'Reset proxy configuration to default settings',
|
||||
'All custom configurations will be lost',
|
||||
'Custom ports and entrypoints will be removed',
|
||||
]"
|
||||
confirmationText="{{ $server->name }}"
|
||||
confirmationLabel="Please confirm by entering the server name below"
|
||||
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
|
||||
:confirmWithPassword="false" :confirmWithText="true">
|
||||
</x-modal-confirmation>
|
||||
@endcan
|
||||
label="Configuration file ( {{ $this->configurationFilePath }} )"
|
||||
name="proxySettings" id="proxySettings" rows="30" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
@@ -239,7 +239,6 @@
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox canGate="update" :canResource="$server" wire:model.live="isSentinelEnabled"
|
||||
label="Enable Sentinel" />
|
||||
@@ -267,9 +266,7 @@
|
||||
$wire.set('sentinelCustomDockerImage', this.customImage);
|
||||
}
|
||||
}" x-init="$wire.set('sentinelCustomDockerImage', customImage)">
|
||||
<x-forms.input
|
||||
x-model="customImage"
|
||||
@input.debounce.500ms="saveCustomImage()"
|
||||
<x-forms.input x-model="customImage" @input.debounce.500ms="saveCustomImage()"
|
||||
placeholder="e.g., sentinel:latest or myregistry/sentinel:dev"
|
||||
label="Custom Sentinel Docker Image (Dev Only)"
|
||||
helper="Override the default Sentinel Docker image for testing. Leave empty to use the default." />
|
||||
|
@@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\Api\ApplicationsController;
|
||||
use App\Http\Controllers\Api\DatabasesController;
|
||||
use App\Http\Controllers\Api\DeployController;
|
||||
use App\Http\Controllers\Api\GithubController;
|
||||
use App\Http\Controllers\Api\OtherController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\ResourcesController;
|
||||
@@ -23,6 +24,7 @@ Route::group([
|
||||
});
|
||||
|
||||
Route::post('/feedback', [OtherController::class, 'feedback']);
|
||||
|
||||
Route::group([
|
||||
'middleware' => ['auth:sanctum', 'api.ability:write'],
|
||||
'prefix' => 'v1',
|
||||
@@ -102,6 +104,12 @@ Route::group([
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
|
||||
Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']);
|
||||
Route::delete('/github-apps/{github_app_id}', [GithubController::class, 'delete_github_app'])->middleware(['api.ability:write']);
|
||||
Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']);
|
||||
Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/databases', [DatabasesController::class, 'databases'])->middleware(['api.ability:read']);
|
||||
Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']);
|
||||
Route::post('/databases/mysql', [DatabasesController::class, 'create_database_mysql'])->middleware(['api.ability:write']);
|
||||
@@ -113,8 +121,13 @@ Route::group([
|
||||
Route::post('/databases/keydb', [DatabasesController::class, 'create_database_keydb'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']);
|
||||
Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']);
|
||||
Route::get('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', [DatabasesController::class, 'list_backup_executions'])->middleware(['api.ability:read']);
|
||||
Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']);
|
||||
|
@@ -1,3 +1,4 @@
|
||||
# ignore: true
|
||||
# documentation: https://posthog.com
|
||||
# slogan: The single platform to analyze, test, observe, and deploy new features
|
||||
# category: analytics
|
||||
|
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.428"
|
||||
"version": "4.0.0-beta.429"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.429"
|
||||
"version": "4.0.0-beta.430"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.11"
|
||||
|
Reference in New Issue
Block a user